C---市场交易系统算法测试和调优-全-
C++ 市场交易系统算法测试和调优(全)
原文:Testing and Tuning Market Trading Systems Algorithms in C++
一、介绍
在我们深入研究这本书的内容(或者豆腐,如果你喜欢的话)之前,我们应该清楚在这里你会发现什么,不会发现什么,以及读者应该做什么样的准备。
目标受众和内容概述
这本书的目标读者是那些统计学背景一般的人(统计学 101 已经足够了),对任何语言都有一些编程技巧的人(这里的例子中使用了非常倾向于传统 C 语言的 C++),以及对金融市场交易感兴趣的人,他们的数学严谨程度远远超过大多数交易者。在这里,你会发现一个有用的算法集合,包括样本代码,这将帮助你调整你的想法到交易系统,有高于*均水*的盈利能力。但是有很多东西你在这本书里找不到。我们从概述这本书的内容开始。
这本书里有什么
本书涵盖了以下主题:
-
如果你的系统涉及到参数的优化,大多数都是这样,你将学会如何确定你的优化系统是否捕捉到了真实的市场模式,或者它只是学习了随机的噪声模式,而这些模式不会再出现。
-
您将学习如何修改线性回归,使其更不容易过度拟合,并且作为一个奖励,将预测器分为有价值的和无价值的。您还将学习如何修改线性回归,以便在适度非线性的情况下使用它。
-
你会发现一个非常通用和强大的非线性优化算法,适用于基于预测模型的交易系统和传统的算法系统。
-
所有的交易系统都假定交易市场有一定程度的一致性;如果你的系统所基于的模式在最*的历史中经常发生,我们必须假设同样的模式至少会持续到不久的将来。一些交易系统对市场模式的适度变化很稳健,而另一些系统会因为市场模式的微小变化而变得一文不值。您将学习如何评估您的系统对这种变化的稳健程度。
-
如果你已经设计了自己的专有指标,你将学会如何确认它们是合理稳定的(这是任何有效指标的关键属性),或者如果它们不稳定,如何将它们调整到*稳状态。你还将学习如何计算它们,以最大化它们的信息含量,最小化它们的噪音,并以有效的方式提供给你的交易系统,以最大化它们的效用。
-
大多数交易系统开发人员都熟悉 walkforward 测试。但没有那么多人意识到,普通的前向算法通常不足以正确验证交易系统候选,而且可能因为微妙的原因产生危险的乐观结果。您将了解如何在第二层 walkforward 分析中嵌入一个 walkforward 算法,或者在 walkforward 分析中嵌入一个交叉验证层。这种“验证中验证”的场景不仅是测试交易系统的最佳方式,也是唯一真正正确的方式。
-
你将学习如何估计你的系统可能产生的未来利润的范围。如果你发现你的系统有几乎确定的未来盈利能力,但是这种盈利能力相对于产生的风险来说很有可能很小,你就知道你的系统还没有准备好交易。
-
你将学习如何估计灾难性下降的概率,即使你的系统运行“正确”
-
您将了解严格的统计测试算法,这些算法能够抵抗偶尔的大赢大输,这些算法会使许多“传统”验证算法无效。
-
许多交易系统开发人员喜欢使用“墙上的意大利面条”的方法来开发交易系统。尽管经常遭到蔑视,但这实际上是一种合法的方法,只要明智地去做。您将学习如何确定众多竞争系统中的“最佳”是否真正值得。
这本书里没有什么
本书不包括以下主题:
-
这本书不是“市场交易者统计学入门”类型的书。假设读者已经熟悉了均值和标准差、正态分布、假设检验的 p 值等概念。不需要比这些概念更高级的东西;这里介绍的高级统计技术是建立在基本概念的基础上的,任何通过了统计学 101 甚至是心理学统计学课程的人都可以掌握。但是如果你不知道什么是标准差,你会发现这本书很难懂。
-
这也不是一本“金融市场交易入门”的书。假设你知道一些术语的含义,比如开始交易的和结束交易的,多头和空头,表示每笔交易的收益。如果你完全是金融市场交易的新手,在阅读这本书之前,你需要学习背景材料。**
** 在这里,你会发现很少或没有实际的、经过验证的交易系统。那些一毛钱一打,通常值这个价。但是如果你对交易系统有自己的想法,你将学习如何实现、测试和调整它,以最大化它的利润潜力。
* 你在这本书里找不到绝密的超级骗子保险指标。提出的几个指标要么是常识,要么在公共领域广为流传。但是如果你对指标有自己的想法,你就会学会如何最大化它们的效用。*
*## 关于交易系统
由于本文中介绍了不同的测试程序,它们必然会在不同的交易系统中进行演示。请注意以下感兴趣的项目:
-
我并不赞同这些系统赚钱。相反,我尽可能地保持系统的简单,这样就可以把重点放在它们的测试上,而不是它们的实际效用上。这本书假设读者对交易系统有自己的想法;这里的目标是为调整和严格测试现有系统提供先进的统计方法。
-
所有用于演示的交易系统都假设我们在使用日线,但这不是必须的。条形可以是任何长度,从几分之一秒到几个月。事实上,大多数演示只使用每根棒线的开盘价或收盘价,所以将这些算法应用于交易分笔成交点数据也是可行的。日柱是最方便的,测试数据也是最容易得到的。
-
大多数演示系统在酒吧收盘时开始和结束交易。自然,在现实生活中这是困难或不可能的;更公*和保守的方法是在一根棒线收盘时做交易决定,在下一根棒线开盘时开始或结束交易。但是这会给这里显示的算法增加不必要的混乱。请记住,我们的目标是以最直接的方式呈现统计算法,将焦点放在统计测试上。在大多数情况下,对实现的小修改不会从本质上改变严格统计测试的结果。
-
在这些测试中,交易成本(滑点和佣金)被故意忽略了,这也是为了保持对统计测试的关注,而不增加混乱。提供的代码和附带的描述清楚地说明了如果需要,如何将交易成本纳入计算中。
市场价格和回报
大多数股票市场的价格范围很广,或许开始时每股交易几美元,经过分割调整后,今天每股交易数百或数千美元。当我们计算交易的回报时,我们不敢只减去开盘价和收盘价。从 1 美元到 2 美元的 1 美元波动是巨大的,而从 150 美元到 151 美元的波动几乎是微不足道的。因此,许多人计算百分比变动,将价格变动除以起始价格,然后乘以 100。这就解决了尺度问题,而且很直观。不幸的是,它有一个问题,这使得它在许多统计分析中是一个糟糕的方法。
百分比移动的问题在于它们是不对称的。如果我们在一笔交易中赚了 10 %,然后在下一笔交易中亏损了 10 %,我们就没有回到起点。如果我们从 100 点涨到 110 点,但是损失了 110 点的 10 %,我们就是 99 点。这似乎并不严重,但是如果我们从不同的角度来看,我们就会明白为什么它会成为一个大问题。假设我们有一个长期交易,市场从 100 移动到 110,我们的下一个交易从 110 回到 100。我们的净权益变化为零。然而,我们记录了 10%的收益,然后是 9.1%的损失,净收益接* 1%。如果我们记录一系列的交易回报进行统计分析,这些错误会很快增加,结果是一个完全没有价值的交易系统可以显示出惊人的净收益!这将使几乎所有的性能测试无效。
有一个简单的解决方案被专业开发人员使用,我将在本书中通篇使用:将所有价格转换为价格的对数,并将交易回报计算为这些对数的差值。这解决了所有的问题。例如,捕捉到从 10 到 11 的市场波动的交易是 2.39789–2.30258 = 0.09531,捕捉到从 100 到 110 的波动的交易是 4.70048–4.60517 = 0.09531。如果一笔交易让我们从 110 回到 100,我们损失 0.09531,净收益为零。太好了。
这种方法的一个好处是,较小的对数价格变化乘以 100,几乎等于百分比变化。例如,从 100 到 101,1%的变化,相当于 100 *(4.61512–4.605)= 0.995。甚至早些时候提到的 10%的变动也相当于 9.531%。出于这个原因,我们将把从日志中计算出的回报视为*似的百分比回报。
两种类型的自动交易系统
最初,所有形式的自动市场交易都是所谓的算法或基于规则的 。系统开发人员提出了一套严格定义的规则来指导开仓和*仓。规则可能会规定,如果某个条件组合变为真,那么您将打开一个多头头寸并持有该头寸,直到某个其他条件组合变为真。算法交易的一个经典例子是均线交叉系统。计算短期和长期均线,如果短期均线高于长期均线,就做多,否则就做空。训练这个原始的交易系统是通过寻找在历史数据集上提供最佳性能的短期和长期回顾来执行的。算法系统,许多涉及几十个条件,今天仍在广泛使用。
最*,许多开发人员(包括我自己)已经形成了这样的观点,即基于模型的系统更强大,尽管它们有一个共同的缺点,即它们经常盲目信任内部工作方式深不可测的黑盒。在基于模型的自动交易中,我们计算一个或多个(通常更多)?? 指标,这些指标是回顾过去并衡量市场特征的变量。这些可能包括趋势、波动性、短期循环行为等等。我们还计算了一个展望未来并描述*期市场行为的目标变量。目标可以是下一根或几根棒线的大小和市场运动方向。目标也可能是一个二元标志,它告诉我们市场在触及保护性止损点之前是否先触及了一个预定义的利润目标。然后,在给定指标变量的值的情况下,我们训练一个预测模型来估计目标变量的值。为了交易该系统,我们用指标的当前值呈现训练的模型,并考虑模型的预测。如果预测足够强(表明有信心),我们根据预测的走势建立市场头寸。
基于模型的交易相对于基于规则的算法交易的优势在于,我们可以利用人工智能领域的许多最新发展,让运行在强大计算机上的复杂程序发现交易系统,这些系统可能非常复杂或模糊,没有人可能希望发现并以明确的规则编程。当然,这需要付出很高的代价:我们通常不知道模型到底发现了什么“规则”,我们必须盲目地接受模型的决定。
因为这两种类型的交易系统开发在今天都被广泛使用,所以本文将迎合这两种思想流派。不可避免的是,这里给出的一些统计检验只适用于其中一个。但是总是会尝试设计测试过程,让从业者可以使用这两种风格。
相信电脑的痛苦
对许多人来说,尤其是经验丰富的凭直觉交易者,走向自动化交易最困难的部分是当计算机的交易决策与他们的直觉冲突时接受它们,更不用说他们多年成功的交易了。我将从我自己的亲身经历中给出一个具体的例子。我根据合同开发了一个短期日内交易系统。我对该系统进行了极其彻底、严格的统计测试,结果明确显示,当该系统在冒着偶尔出现巨额亏损的风险(一种非常宽松的保护性止损)的情况下获取大量小额利润时,其利润最大化。这惹恼了负责将信号交易传到场内的交易员。他不断地用他的口头禅“减少你的损失,让你的胜利奔跑”来轰炸我。这是一些交易风格的真理,但不是这个特殊的系统。他控制不住自己;他一直否决电脑的交易决定。系统会要求*仓赢利的交易,但他会保持*仓,希望有更大的收益。或者,市场会朝着一个未*仓的位置移动,他会在系统止损点到达之前很久就把它*仓。他一直告诉我,如果他让它继续下滑,而不是早点止损,会损失多少钱。并行运行的计算机模拟赚了很多钱,而他的修改版本赚得少得多,这一事实对他的观点没有影响。多年来,他一直是一个成功的全权交易者,他知道如何交易,没有#$%^的电脑会告诉他别的方法。我们的关系从未成功过。这个故事的寓意是:如果你没有勇气相信自动化交易,那就忘掉它吧。
未来的泄露比你想象的更危险
未来泄露是未来知识非法泄露到测试程序中。在交易系统的开发和测试中,当未来市场行为的某个方面模拟了交易系统在现实生活中的表现时,就会发生这种情况。因为当我们交易我们的系统时,我们显然不知道未来,这种泄漏导致乐观的性能估计。
我不止一次地惊讶于原本严肃的系统开发人员如此随意地采取这种形式的欺骗。我有过聪明的、受过教育的开发人员耐心地向我解释说,是的,他们确实理解一些小程度的未来知识参与了他们的性能模拟。但是他们又煞费苦心地解释这种“不可避免的”泄漏是如此之小,以至于微不足道,不可能对他们的结果产生任何实质性的影响。他们不知道。这就是为什么本文反复强调的重点是避免未来泄漏的方法,即使是最微小的接触。在我早期的系统开发中,我经常惊讶于这种泄漏是如此的微妙。
为了强调这一点,图 1-1 显示了一个几乎随机的赢 1/输 1 交易系统的股票曲线,只有 1%的优势。这条曲线,如果它真的是随机的(无价值的),*均来说会是*坦的,仅仅从这个微小的边缘来看是相当可观的。未来的泄露远比你想象的更致命。认真对待。
图 1-1
有 1%边的随机系统的权益曲线
百分比获胜谬论
有一个简单的数学公式,对交易系统的开发和评估至关重要,但对许多人来说似乎很难接受,即使他们在智力上理解它。见方程式 1-1 。
(1-1)
这个公式表示,交易的预期回报(如果这种情况重复多次,我们将获得的*均回报)等于我们将赢的金额乘以赢的概率减去我们将输的金额乘以我们将输的概率。
很容易接受的是,如果我们掷一枚公*的硬币,正面赢一美元,反面输一美元,我们的预期收益是零;如果我们重复抛硬币很多次,从长期来看,每次抛硬币的*均回报是零。同样容易接受的是,如果硬币是公*的,我们赢了两美元,但只输了一美元,我们就处于令人羡慕的地位。
现在考虑交易一个真正随机游走的市场;在其他属性中,从一个条形到下一个条形的变化都是相互独立的,并且*均值为零。开发一个除了零期望(当然忽略交易成本)之外的交易系统是不可能的。但是我们可以很容易地改变预期的输赢,以及输赢的频率。
例如,假设我们建立了一个多头头寸,并在进场价格之上设置了 1 个点的利润目标,在进场价格之下设置了 9 个点的止损出场。每一次我们经历的损失,都是巨大的,是我们赢的 9 倍。但是如果我们在假设的随机市场上执行大量这样的交易,我们会发现我们赢的次数是输的 9 倍。我们赢了 90%的机会。根据等式 1-1 ,我们每笔交易的预期回报仍然是零。这里的要点是,输赢的大小和概率是密不可分的。如果有人吹嘘他们的交易系统多长时间赢一次,问问他们的盈亏情况。如果他们吹嘘他们的赢和他们的输相比是多么巨大,问问他们多久赢一次。二者都不是孤立存在的。*
二、预优化问题
评估和改善*稳性
从本质上来说,时间序列的*稳性(如市场价格变化、指标或单个交易收益)是指其统计属性随时间保持不变的程度。统计学家可能会对如此宽松的定义感到畏缩,但这抓住了这个术语的实际意义。当我们使用市场历史来创建一个(最好是)有利可图的交易系统时,我们隐含地指望产生回溯测试盈利能力的历史模式至少在不久的将来仍然有效。如果我们不愿意做出这样的假设,我们还不如放弃交易系统的设计。
这个概念有许多方面与金融市场的自动交易特别相关。
-
市场,以及从市场历史中得出的指标和交易回报,本质上是不稳定的。它们的属性不断变化。唯一的问题是:情况有多糟?我们能处理吗?我们能解决问题让它变得更好吗?
-
对非*稳性进行任何严格的传统统计检验都是没有意义的。事实上,我们进行的任何测试都会显示出非常显著的统计非*稳性,所以我们不必费心;我们已经知道答案了。
-
非*稳性可以有无数种形式。也许方差在一段时间内是相当恒定的,而*均值则是漂移的。反之亦然。或者偏斜度可能改变。或者…
-
一些类型的不稳定可能对我们无害,而另一些可能对我们的交易系统是毁灭性的。一个交易系统可能有一种不稳定的弱点,而另一个交易系统可能被不同的东西拖累。在评估*稳性时,我们必须尽可能地考虑上下文。
-
评估一个完成的交易系统的耐用性的最好方法是使用第 142 页给出的渐进向前算法。
但是我们将忽略最后一点。这一章专门讨论在交易系统的发展过程中,我们应该在之前考虑的问题。渐进向前行走出现在开发的最后,是几个最终验证过程中的一个。
排除了非*稳性的传统统计测试,那么你应该怎么做?你绝对必须仔细研究你的指标。你可能会对你所看到的感到惊讶。它们的中心趋势可能会慢慢上下波动,使得预测模型在一个或两个极端都没有用。日复一日的徘徊是正常的,但缓慢的徘徊,或方差的缓慢变化,是一个严重的问题。如果一个指标在回到更“正常”的行为之前花了几个月甚至几年的时间,模型可能会在这些延长的时间内关闭或做出错误的预测。我们必须警惕这种灾难性的情况,如果我们不小心的话,这种情况很容易发生。
有时我们可能没有指标来绘制。下一节中显示的 STATN 程序是一个有价值的替代方案。但是理解非*稳性的潜在问题是很重要的。设计一个年复一年运行良好的自动交易系统,不需要调整,甚至完全重新设计,是非常困难的。市场总是变化的。我们很容易陷入的陷阱是设计一个在回溯测试中表现良好的系统,但其令人鼓舞的表现仅仅是因为在我们回溯测试历史中有利的一段时间里表现突出。因此,我们必须研究我们系统的权益曲线。如果它只在一小部分时间里表现出色,而在其他时间表现**,我们就应该仔细考虑这种情况。当然,如果前一段时间表现出色,而最*的表现有所恶化,那就更是如此了!
关键的一点是,当我们在某种市场条件下开发一个交易系统时,只有在这种市场条件下,我们才能期待持续的良好表现。因此,我们希望在我们的开发和测试期间,市场条件变化足够频繁,以便所有可能的市场条件都能得到体现。而且即使所有条件都表现出来了,缓慢的徘徊也可能引起周期性的延长的不利表现。长时间的出色表现,接着是长时间的糟糕表现,会令人沮丧。
STATN 计划
对于我们这些渴望硬数字的人来说,有一个很好的测试,它比基于目测情节的武断决定更可靠。我已经在 STATN.CPP 程序中提供了这个算法的示例。这个版本读取市场历史文件,并检查市场随时间的趋势和波动性。您可以通过添加其他市场属性(如 ADX 或您使用的任何自定义指标)来轻松修改它。
这个程序的原理很简单,但却令人惊讶地揭示了市场异常。它基于这样一种想法,即在特定市场条件下开发的交易系统(如上升或下降趋势,高或低波动性)在其他市场条件下可能会失去盈利能力。在大多数情况下,我们希望看到反映在我们的指标中的这些条件在规则和合理随机的基础上变化,以便我们开发的系统将尽可能多地经历投入使用时会遇到的各种条件。缓慢的徘徊是危险的非*稳性的本质;市场属性可能会在一段时间内保持在一个状态,然后在另一段时间内变为另一个状态,同样会影响我们的指标。这使得开发健壮的模型变得困难。粗略地说,*稳性等于行为的一致性。
使用以下命令调用该程序:
STATN Lookback Fractile Version Filename
让我们来分解这个命令:
-
Lookback
:历史棒线的数量,包括当前棒线,用于计算市场的趋势和波动性。 -
Fractile
:趋势和波动性的分位数(0–1),用作差距分析的上/下阈值。 -
Version
: 0 表示原始指标,1 表示差异原始指标,> 1 表示指定原始减去扩展原始。详见第 14 页。 -
Filename
:格式为YYYYMMDD Open High Low Close
的市场历史文件。
使用真实市场数据的例子将出现在第 17 页。首先,我们探索一些代码片段。参见 STATN。完整上下文的 CPP。
该程序遍历市场历史,计算趋势(最小二乘线的斜率)和波动性(*均真实范围)。它查找对应于指定分位数的分位数;0.5 将是中间值。对于每根棒线,它决定趋势和波动的当前值(或它们的修改值,如后所述)是小于分位数还是大于或等于分位数。每次状态改变时(从上面到下面或从下面到上面),它会记录已经过了多少个小节并记录下来。例如,如果下一个条形的状态发生变化,则计数为 1。如果状态在下一个条之后改变了一个条,则计数为 2,依此类推。对于 1、2、4、8、16、32、64、128、256、512 和大于 512 的条计数,定义了 11 个仓。当程序结束时,它打印仓位计数,一个趋势表和一个波动表。
Version
参数需要更多一点的解释,其理由将推迟到下一节。现在,请理解,如果用户将其指定为 0,趋势和波动指标将完全按照计算结果使用。如果它是 1,每个指示器的当前值通过减去它在lookback
条之前的值来调整,使它成为一个经典振荡器。如果它大于 1,则通过使用Version * Lookback
的回看减去该值来调整当前值,使其成为另一种振荡器。后两个版本要求实际回看大于用户指定的回看,如以下代码所示:
if (version == 0)
full_lookback = lookback ;
else if (version == 1)
full_lookback = 2 * lookback ;
else if (version > 1)
full_lookback = version * lookback ;
nind = nprices - full_lookback + 1 ; // This many indicators
如果nprices
是价格条的数量,我们丢失其中的full_lookback–1
,得到指标的nind
值,如前面代码的最后一行所示。
以下代码块显示了趋势指标(可能已修改)的计算。波动性也是如此。对于每一遍,k
是指示器当前值的索引。我们必须从足够远的指标历史开始,以涵盖完整的回顾。
for (i=0 ; i<nind ; i++) {
k = full_lookback - 1 + i ;
if (version == 0)
trend[i] = find_slope ( lookback , close + k ) ;
else if (version == 1)
trend[i] = find_slope ( lookback , close + k ) –
find_slope ( lookback , close + k - lookback ) ;
else
trend[i] = find_slope ( lookback , close + k ) –
find_slope ( full_lookback , close + k ) ;
trend_sorted[i] = trend[i] ;
}
对值进行排序以找到用户指定的分位数,然后计算每个箱中的计数。
qsortd ( 0 , nind-1 , trend_sorted ) ;
k = (int) (fractile * (nind+1)) - 1 ;
if (k < 0)
k = 0 ;
trend_quantile = trend_sorted[k] ;
gap_analyze ( nind , trend , trend_quantile , ngaps , gap_size , gap_count ) ;
在调用gap_analyze()
之前,我们必须做一些准备,为其提供间隙尺寸的边界。如果你想的话,可以随意修改。分析代码出现在下一页。
#define NGAPS 11 /* Number of gaps in analysis */
ngaps = NGAPS ;
k = 1 ;
for (i=0 ; i<ngaps-1 ; i++) {
gap_size[i] = k ;
k *= 2 ;
}
该例程仅保留一个标志above_below
,如果当前值等于或高于阈值,则该标志为真 (1),如果低于阈值,则为假 (0)。对于每次循环,如果指示器仍然在阈值的同一侧,则计数器递增。如果它转换了方向,则相应的 bin 递增,并且计数器复位。到达数组末尾相当于翻转两边,所以最后一个系列才算。
void gap_analyze (
int n ,
double *x ,
double thresh ,
int ngaps ,
int *gap_size ,
int *gap_count
)
{
int i, j, above_below, new_above_below, count ;
for (i=0 ; i<ngaps ; i++)
gap_count[i] = 0 ;
count = 1 ;
above_below = (x[0] >= thresh) ? 1 : 0 ;
for (i=1 ; i<=n ; i++) {
if (i == n) // Passing end of array counts as a change
new_above_below = 1 - above_below ;
else
new_above_below = (x[i] >= thresh) ? 1 : 0 ;
if (new_above_below == above_below)
++count ;
else {
for (j=0 ; j<ngaps-1 ; j++) {
if (count <= gap_size[j])
break ;
}
++gap_count[j] ;
count = 1 ;
above_below = new_above_below
;
}
}
}
通过振荡改善位置*稳性
提高指标稳定性的一个简单但通常有效的方法,至少就其中心趋势而言,是计算其相对于某个相关“基础”值的值。最常见也是最有效的方法是减去滞后值,滞后值通常(但不一定)是指标的回望值。例如,我们可以计算最* 20 个价格的趋势,并从中减去 20 根棒线之前该指标的值。
一个相似但不完全相同的方法是计算当前时间的指标,但有两个不同的回顾,一个短,一个长。从短期指标中减去长期指标,得到一个更稳定的修正指标。
这两种方法都涉及到重要的权衡。可能是指标的实际值承载了重要的信息。刚刚描述的两个修改放弃了实际值,而选择了相对值。根据我的经验,后一个值通常比实际值携带更多的预测信息,并且在几乎所有情况下都有更好的稳定性。但这并不具有普遍性,这种权衡必须牢记在心。
如果这种权衡是一个问题,请记住,第一种方法,即找出指标的当前值和滞后值之间的差异,是最“强大”的,因为它通常导致最大的稳定性,同时也丢弃了关于真实当前值的大多数信息。第二种方法更像是一种妥协。此外,通过调整长期回顾,人们可以对这种权衡施加很大的控制。增加长期回顾可以更好地保存当前值的信息,但代价是*稳性的改善较少。
在下一页,我们可以看到 STATN 程序制作的两个表格,回顾值为 100,S&P 100 指数 OEX 的分位数为 0.5(中位数)。上表是趋势,下表是波动。第一列是原始指标;第二个是Version
=1,滞后差;而第三个是Version
=3,给出 300 棒的长期回看。
Trend with Lookback=100, Fractile=0.5
Gap Version=0 Version=1 Version=3
1 3 1 0
2 3 1 0
4 2 2 2
8 5 2 1
16 4 3 4
32 14 2 12
64 22 14 25
128 29 54 33
256 18 15 21
512 3 1 1
>512 0 0 0
Volatility with Lookback=100, Fractile=0.5
Gap Version=0 Version=1 Version=3
1 13 41 19
2 6 13 6
4 2 9 13
8 2 8 6
16 4 9 4
32 2 10 10
64 3 12 8
128 5 25 10
256 9 23 18
512 2 5 9
>512 6 0 1
在这个趋势表中,我们看到原始指标有三个长时间段,在这三个时间段中,该指标保持在其中值的同一侧。这些周期大于 256 根连续棒线,也许长达 512 根棒线,超过两年!两个修改版本只有一个这样的周期。
波动性的情况甚至更严重,原始指标有六个时间段大于 512 棒线,波动性位于中值的同一侧。修改极大地改善了这种情况,尽管在下一个较低的级别会有显著的恶化。波动一般具有极端的非*稳性。
极端*稳归纳
刚刚描述的两种方法只在指标的中心趋势中引入*稳性。这很重要,可以说是*稳性最重要的品质。如果一个指标缓慢波动,长时间停留在高值,然后长时间移动到低值,这个指标在交易系统中的效用可能会受损。基于此类指标的系统很容易长期亏损,甚至停止交易。当然,在某些情况下,停止交易是有用的;如果你有几个互补的系统,如果每个系统都在盈利交易和不交易之间交替,那就太好了。不幸的是,在现实生活中,这样的系统只是例外,而不是规则。
但是一个指标有无数种不稳定的方式。集中趋势(*均值)通常是最重要的,其次是方差。如果一个指标在很长一段时间内变化很小,然后在随后的很长一段时间内变化很大,则该指标将受损。
有一种简单的方法可以使均值、方差或两者达到一个极端但可控的程度。只需回顾指标最*值的移动窗口,并计算该窗口内的*均值(如果指标表现良好)或中值(如果偶尔出现极值)。从当前值中减去该值,以在中心趋势中引入稳定性。如果窗口很短,效果会很明显,足以克服几乎任何程度的不稳定性。同样,您可以计算移动窗口的标准差(如果指标表现良好)或四分位间距(如果出现野值)。将(可能居中的)当前值除以该量,以引起方差的*稳性。
没有提供这种方法的例子,因为它是一种简单的计算。请记住,长窗口将保留有关指标实际值的大量信息,同时提供很少的非*稳性减少。相反,短窗口会破坏几乎所有关于实际值的信息,使一切都与最*的历史相关,从而导致巨大的*稳性。
用熵度量指标信息
几十年前,贝尔实验室的 Claude Shannon 开发了一种严格且极其强大的方法来量化一条消息可以传达的信息量。这与交易系统的开发有关,因为从最*的市场历史计算的指标可以被认为是来自市场的信息,传达了关于市场的当前和可能的未来状态的信息。如果我们可以量化指标中的*均信息,我们就可以了解该指标的潜在价值。更好的是,我们可以修改指标,增加其信息含量。并非巧合的是,这些增加信息的修改与众所周知的提高预测模型性能的修改完全相同。这是一个值得研究的领域。
我们将采取一种肤浅的、直观的方法来量化指标中的*均信息。关于这个主题的更详细的探索,请参阅我的两本书c++中的数据挖掘算法或者评估和改进预测和分类。
假设需要传达一条信息,这条信息就是一道选择题的答案。也许这是一个简单的二元选择,例如“市场处于上升趋势状态”与“市场处于下降趋势状态”或许再详细一点,比如四种可能的情况:“市场处于强涨/弱涨/弱跌/强跌”状态。现在添加一个限制,即消息必须是二进制的,即一个或多个 1 和 0 的字符串。显然,第一个问题的答案可以用一个二进制位给出,而第二个问题的答案将需要两个位来涵盖四种可能的市场状态(00,01,10,11)。一般来说,如果有 K 个可能的答案,那么我们将需要消息中的 log 2 ( K )位来传达正确的答案。
量化信息价值的一个好方法是它所传达的信息的位数。为消息赋值的一种不太清楚但更有用的方法是通过接收消息来消除不确定性的位数。假设你参加了一个总共有 1024 张彩票的抽奖,其中一张是你的。获胜者的身份可以用 log 2 (1024)=10 位编码。在你收到任何信息之前,你对获胜者的身份有 10 点不确定性。相当于,每个条目都有 1/1024 的机会成为赢家。
收到一条信息,回答一个简单的问题:你是否赢得了彩票。让我们计算这两个可能答案的价值。如果答案是你中了彩票,则解决了概率为 1/1024 的事件,给该特定消息的值为 log2(1024)=–log2(1/1024)= 10 位。如果答案是您没有赢,则概率为 1023/1024 的事件已经解决,给该特定消息的值为–log2(1023/1024)= 0.0014 位。
大多数人(和计算机)不使用 base 2 中的日志。相反,他们使用自然对数。当这样做时,信息的单位是 nat,而不是比特。所以,在讨论的例子中,你赢了的一个答案的值是–log(1/1024)= 6.93 NATs,失望答案的值是–log(1023/1024)= 0.00098 NATs。
我们只是计算了每个答案的价值。但是我们也对消息的预期价值感兴趣。回想一下,离散随机变量的期望值是每个值乘以该值的概率的乘积之和。因此,消息的期望值是你赢得答案的概率乘以它的值,加上你没有赢得答案的概率乘以它的值。这是 1/1024 –log(1/1024)+1023/1024 –log(1023/1024)= 0.0077 纳特。这个期望值被称为消息的熵,并且被符号化为 H 。
我们可以更严谨一些。设 χ 是一个集合,它枚举了一个消息流 X 中每一个可能的答案。因此, χ 可能是{ 大幅度上升趋势、小幅度上升趋势、小幅度下降趋势、大幅度下降趋势 }。当我们观察到一个 X 的值时,我们称之为 x ,根据定义它总是 χ 的成员。这就写成了 x ∈ χ 。设 p ( x )为 x 被观测到的概率。那么 X 的熵由方程 2-1 给出。在这个等式中,0*log(0)被定义为零。
(2-1)
我们在没有证明的情况下陈述,当每个可能的答案( x 的值)具有相等的概率时,消息流 X 的熵(*均信息量)最大,并且这个最大熵是 log( K ,其中 K 是 x 的可能值的数量。因此,我们将对H(X)/log(K)的值最感兴趣,因为这个数字的范围从零(消息流不传递任何信息)到一(消息流传递最大可能的信息量)。这个比值称为相对熵或比例熵。
最后,我们可以将所有这些(高度简化的)理论与自动市场交易联系起来。我们要做的是筛选我们交易系统中使用的所有指标的相对熵。如果相对熵很小,我们应该考虑用不同的方法计算指标,也许可以采用一种简单的方法,比如应用非线性变换来增加相对熵。在我自己的工作中,我希望相对熵至少为 0.5,甚至更高,尽管这个阈值是非常随意的。
有几个注意事项要记住。首先,理解熵是信息内容的度量,但是我们不知道这些信息是否与手头的任务相关。一个指标可能在预测市场波动是否会在接下来的一周爆发方面做得非常出色。但是,如果我们的目标是确定我们是做多还是做空,这个信息丰富的指标对我们的项目来说可能毫无价值。然而,熵可以被认为是信息内容的上限,所以如果熵很小,我们的指标可能没有什么价值。
第二,可能发生的情况是,无论我们做什么修改来增加指标的熵,实际上都会妨碍指标的性能。也许我们最初的想法作为一个指标做得很好,但是当我们应用一个看似无害的改变来大大增加它的熵时,它在我们交易系统中的效用下降了。这是可能发生的。但是请理解,这两种情况,尤其是第二种情况,是不寻常的例外。在绝大多数情况下,增加指标的熵可以显著提高其性能。
计算指标的相对熵
从指标的历史值计算指标的相对熵的最简单也可能是最好的方法是,将指标的整个范围划分为多个区间,这些区间以相等的间距划分该范围,计算落入每个区间的事例的比例,并使用等式 2-1 来计算熵。将这个量除以箱数的对数,得到相对熵。请注意,将范围划分为包含相同数量事例的箱是没有意义的,因为这将始终给出一个相对熵。相反,仓必须由总范围的相等数字部分来定义。下面是一个简单的子例程:
double entropy (
int n , // Number of data values
double *x , // They are here
int nbins , // Number of bins, at least 2
int *count // Work area nbins long
)
{
int i, k ;
double minval, maxval, factor, p, sum ;
minval = maxval = x[0] ;
for (i=1 ; i<n ; i++) {
if (x[i] < minval)
minval = x[i] ;
if (x[i] > maxval)
maxval = x[i] ;
}
factor = (nbins - 1.e-10) / (maxval - minval + 1.e-60) ;
for (i=0 ; i<nbins ; i++)
count[i] = 0 ;
for (i=0 ; i<n ; i++) { // Count the number of cases in each bin
k = (int) (factor * (x[i] - minval)) ;
++count[k] ;
}
sum = 0.0 ;
for (i=0 ; i<nbins ; i++) { // Sum Equation 2-1
if (count[i]) {
p = (double) count[i] / n ;
sum += p * log ( p ) ;
}
}
return -sum / log ( (double) nbins ) ;
}
在前面的代码中,我们必须对将数据值映射到 bin 的因子的计算进行两次微小的旋转。分子被略微减小,以确保在最后一个 bin 之后没有映射到不存在的“bin”。修改分母,以确保在所有数据值相等的病态情况下,我们不会被零除。最后一个循环只是对等式 2-1 求和,我们通过将熵除以其最大可能值来得出相对熵。
熵影响预测模型的质量
熵作为衡量指标信息含量的有用性不仅仅是理论上的空谈。不管是不是巧合,熵与我们训练有效预测模型的能力高度相关。这是因为高熵与指标范围内数据值的大致相等分布相关,并且在大多数情况下,当模型的指标具有这样的分布时,模型训练最有效。
最常见的有问题的低熵情况是当有一个或多个极端异常值时。许多模型训练算法会将一个异常值视为说了一些重要的事情,并对该异常值投入大量注意力。这减少了对大量“正常”病例的关注。图 2-1 展示了这种情况的一个有点简单但通常很现实的例子。这是一个处理两个类的线性分类器,应该是一个玩具般简单的问题。虚线示出了实现完美分类的线性边界。但是左下角的那个例子,是 X2 指标的异常值,把边界线往它的方向拖,严重损害了分类质量。尽管这个特定的例子以线性分类器为特征,但是即使是具有弯曲边界的非线性分类器,也会经常遭受同样的退化。
图 2-1
离群值会降低性能
由于低熵,不需要离群值来产生性能下降。假设我们有这样一种情况,在这种情况下,与预测/分类完全无关的一些外部条件,在大约一半的情况下,在另外一个优秀的预测器中引起一个大的固定偏移。也许这个变量在一半的情况下有 1.0 左右的值,在这些情况下有很好的表现。同样假设在另一半的情况下,它的值在 100.0 左右,并且它在这个组中也有极好的功效。没有多少模型能够处理这种极低熵的情况。他们会将聚类在 1.0 左右的病例和聚类在 100.0 左右的病例之间的分离视为主导因素,并专注于使用该聚类成员来尝试预测/分类。结果不会很好。
提高指标的熵
如果你测试一个指标,发现它有危险的小熵(小于 0.5 是可疑的;低于 0.1 是严重的,应该进行调查并可能解决),那么你的第一步应该是重新考虑你的指标想法是如何实现的。也许对你的计算算法做一个简单的修改就能解决这个问题,而不会影响你的想法。以下是一些需要考虑的其他想法:
-
如果你的指标计算除以一个可能变小的值,你就如履薄冰了。
-
你的修改应该与你最初的想法单调相关。换句话说,如果修订前的情况 A 小于修订前的情况 B,那么在修订后应该保持相同的顺序。在其他期望的属性中,这确保了如果某个阈值在修订前的基础上分离病例,则存在将在修订后执行相同分离的阈值。这是保存信息的一个重要品质。
-
截断(将极值重新映射到单个极限值)是解决异常值问题的一种糟糕方法。除此之外,它违反了刚刚列出的优先原则!
-
如果您只有一些罕见的异常值,单调仅尾修改是一个很好的解决方案,它极大地提高了熵,但对指标值的影响相对较小。选择一个中等的百分位数,低异常值可能在 1-10%之间,高异常值可能在 90-99%之间。在这一阈值的“好”侧的情况保持不变。此阈值“异常值”一侧的情况会受到极端单调压缩,如对数压缩。这将在第 29 页详细讨论。
-
如果只有右尾较重或为正偏斜(仅在异常大的情况下),*方根或立方根变换将处理中等偏斜或异常值,而对数变换应处理严重情况。
-
如果两个尾部都很重,考虑立方根变换。
-
如果两个尾部都非常重或有严重的异常值,双曲正切函数(方程 2-2 和图 2-2 )或逻辑函数(方程 2-3 和图 2-3 )可以提供很好的结果,前提是在应用该函数之前对指标值进行了适当的预标定。如果使用逻辑函数,最好在变换后减去 0.5,使其位于零的中心,这是许多训练算法所欣赏的。
(2-2)
(2-3)
图 2-3
逻辑函数
图 2-2
TANH 函数
-
如果您的指标应该具有类似于常见统计分布的分布有理论上的原因,那么通过应用该分布的累积分布函数进行转换可能是有效的。例如,许多指标(即两个移动*均线之差的振荡器)有一个漂亮的钟形曲线形状,除了适度沉重的尾部,这几乎是正常的,尾部不严重,但坏到足以引起麻烦。在 STATS 中应用普通 cdf (
normal_cdf()
)。CPP)会做得很出色。其他指标可能是两个类方差量的比值,在这种情况下,在 STATS 中为 F CDF (F_CDF
)。CPP)是理想的。 -
有时,您的指标的分布可能会以一种不直接的方式出现问题。例如,考虑由第 25 页描述的外部条件引起的聚集,其中指标具有良好的紧凑分布,完全没有异常值,但是数据聚集成几个小的聚集。或者它可能有这个问题加上一个重尾,或者两个重尾。当这种情况发生时,有一种粗暴的方法,这种方法很笨拙,但是非常有效和通用,特别是如果您有大量具有代表性的指标值样本。按升序对您的样本进行排序,并选择性地保存以备将来使用。然后,要转换一个值,请使用二分搜索法来限制排序数组中的值。转换后的值是小于或等于转换前值的已排序元素的数量。这产生了具有非常接*完美的相对熵的变换指标。当样本很大,具有充分的代表性,并且很少或没有联系时,这种方法效果最好。作为最后一步,将这个数除以元素总数,然后减去 0.5。这给出了一个范围从-0.5 到 0.5 的值,这个范围对许多训练程序特别友好。
-
刚刚介绍的许多技术力求产生一种尽可能在其范围内均匀的指示剂分布。但有时这并不理想,尽管它有最大熵。当指标的极端值确实具有特殊意义,但这种极端值阻碍甚至阻止预测模型的正确训练时,就会发生这种情况。在这种情况下,你想做的只是驯服尾巴,而不是消灭它们。如果您已经采用了一种变换来生成几乎均匀的分布,但是您希望原始极值映射到足够突出但又不会极端到有问题的值,有一个简单的解决方法:变换到正态分布。这种分布具有钟形曲线形状,其中大多数情况集中在内部,但在两个尾部都有适度的极值。为此,首先应用将指示器映射到几乎均匀分布的任何变换。然后使用逆正态累积分布函数进行第二次变换。这可以通过调用 STATS.CPP 中的函数
inverse_normal_cdf()
来完成。得到的指标仍会有极值,但不足以降低模型训练。
单调仅尾部清洗
有时候,你对你的指标的分布总体上是满意的,除了它偶尔会有一个野值,需要驯服以产生适当的熵。或者,预测模型交易系统中的目标偶尔会出现极端情况,妨碍你的训练算法,但你不想过多干预目标值,以免过分扭曲业绩数据。这种情况要求只影响最极端值的转换,而不影响大多数情况。这里有一个很好的处理方法。
这种转变分两步进行。第一步识别标记低尾和高尾的数据值,第二步修改尾(仅)。这里显示的版本以相同的方式处理两个尾部。读者可以很容易地修改它,只处理一个尾部,或者不同地处理上下尾部。
如下所示调用子例程。用户提供了一个工作向量,因为我们必须对原始数据进行排序来定位尾部。调用者指定将被单调压缩的每个尾部的分数(通常很小,可能是 0.01 到 0.1),保留顺序关系,同时强烈引入离群值。变量cover
是保持不变的病例比例。我们将原始数据复制到工作区,并对其进行分类。
void clean_tails (
int n , // Number of cases
double *raw , // They are here
double *work , // Work area n long
double tail_frac // Fraction of each tail to be cleaned (0-0.5)
)
{
int i, istart, istop, best_start, best_stop ;
double cover, range, best, limit, scale, minval, maxval ;
cover = 1.0 - 2.0 * tail_frac ; // Internal fraction preserved
for (i=0 ; i<n ; i++)
work[i] = raw[i] ;
qsortd ( 0 , n-1 , work ) ; // Sort data ascending
识别尾部的一个好方法是检查排序数组中具有指定覆盖范围(包含所需数量的“内部”事例)的每个可能的连续事例集。找出范围(最大值减去最小值)最小的集合。那么,将位于这个最小范围内部集合之外的那些情况标为尾部是合理的。该内部设置将被识别为从istart
到istop
运行。
istart = 0 ; // Start search at the beginning
istop = (int) (cover * (n+1)) - 1 ; // This gives desired coverage
if (istop >= n) // Happens if careless user has tail=0
istop = n - 1 ;
我们从最左边到最右边,在每一个可能的位置运行这个内部集合。对于每组试验终点,找出范围并跟踪哪个位置的范围最窄。
best = 1.e60 ; // Will be minimum span
best_start = best_stop = 0 ; // Not needed; shuts up LINT
while (istop < n) { // Test every possible position
range = work[istop] - work[istart] ; // This is what we minimize
if (range < best) {
best = range ;
best_start = istart ;
best_stop = istop ;
}
++istart ;
++istop ;
}
此时,我们已经找到了最窄的内部集合。获取其下限值和上限值,并防止粗心的呼叫者。
minval = work[best_start] ; // Value at start of interior interval
maxval = work[best_stop] ; // And end
if (maxval <= minval) { // Rare pathological situation
maxval *= 1.0 + 1.e-10 ;
minval *= 1.0 - 1.e-10 ;
}
最后一步是修改尾部(只修改尾部)。通过使用maxval–minval
作为缩放常数,我们保持该过程不受数据缩放变化的影响。limit
变量控制变换后的尾部值位于内部范围之外的程度。使用(1.0–cover)
这个因素是我自己的启发,在我看来是合理的。不同意也可以,想改就改。
读者应该检查这段代码并确认limit
确实定义了转换值的偏离极限,即最小值和最大值(以及它们之间的值!)保持不变,并且变换是单调的(保序的)。
limit = (maxval - minval) * (1.0 - cover) ;
scale = -1.0 / (maxval - minval) ;
for (i=0 ; i<n ; i++) {
if (raw[i] < minval) // Left tail
raw[i] = minval - limit * (1.0 - exp ( scale * (minval - raw[i]) ) ) ;
else if (raw[i] > maxval) // Right tail
raw[i] = maxval + limit * (1.0 - exp ( scale * (raw[i] - maxval) ) ) ;
}
}
熵程序
文件熵。CPP 包含一个完整的程序,演示了从市场价格历史文件计算的各种指标的熵的计算。其中两个指标是第 13 页描述的 STATN 程序中的趋势和波动指标。此外,这里的版本参数与 STATN 程序中的相同,尽管它在熵的上下文中不如在*稳性中有趣。
使用以下命令调用该程序:
ENTROPY Lookback Nbins Version Filename
让我们来分解这个命令:
-
Lookback
:历史棒线的数量,包括当前棒线,用于从市场价格历史计算指标。 -
Nbins
:用于计算熵的箱数。对于一个至少有几千条记录的市场历史来说,大约 20 个左右的仓位是好的,尽管实际上这个数字并不太重要。如果少量改变容器的数量会导致计算出的熵发生很大的变化,那么数据或读者设计的任何自定义指标都有问题。绘制直方图! -
Version
: 0 表示原始指标,1 表示差异原始指标,> 1 表示指定原始减去扩展原始。详见第 14 页。 -
Filename
:格式为YYYYMMDD Open High Low Close
的市场历史文件。
计算以下指标,并打印其最小值、最大值、中值和相对熵。
-
趋势是由最小二乘拟合定义的每根棒线的(对数)价格变化。
-
波动率是根据标准定义计算的*均真实范围。
-
扩展是一个故意设计得很差的指标,它展示了而不是如何定义一个指标,以及低熵如何揭示问题。收盘价的范围(最大收盘价减去最小收盘价)是为覆盖指定回看距离一半的最*价格计算的。然后计算相同的量,但是滞后于回看的一半。扩展指标是最*的范围除以旧的范围,分母略微增加以防止被零除。该指标揭示了波动性(价格范围)的粗略度量是增加、减少还是保持不变。
-
RawJump 衡量最*收盘价与最*指数*滑收盘价的对比情况。这个量揭示了市场是突然上涨还是下跌,还是保持不变。它的两端偶尔会有异常值,因此熵值很低。
-
CleanedJump 是第 29 页描述的单调尾部*滑应用于每个尾部的外部 5%后的 RawJump 。
当熵程序在标准普尔 500 市场历史上运行时,使用 20 根棒线和 20 个仓来计算熵,计算第一列中的相对熵值。当回看下降到七个小节时,我们得到的结果显示在第二列中。
| *趋势* | Zero point five eight | Zero point four eight three | | *波动性* | Zero point six three nine | Zero point five five nine | | *膨胀* | Zero point four six one | Zero | | *拉弗 Jump* | Zero point four eight four | Zero point three nine five | | 清洁跳跃 | Zero point nine five eight | Zero point nine five two |特别是对于较短的回望,趋势和波动指标的相对熵勉强可以接受。他们可以稍微调整一下。一些温和的东西可能会做。扩张指标,通过使用不稳定的比率故意设计得很差,在回顾七根棒线时变得一文不值。请特别注意这样一个事实,即 RawJump 指标的相对熵从差到优,只不过是清理了外部 5%的尾部,其他什么也没碰。
三、优化问题
正则化线性模型
如果我只能让这本书的读者有一个想法,那就是:你的指标的强度比用它们来指示交易的预测模型的强度重要得多。这些年来我见过的一些最好的、最稳定的、最赚钱的交易系统,都是使用简单的线性或接*线性的模型,以高质量的指标作为输入。我也见过太多的人向一些现代的、高度复杂的非线性模型提供边际指标,徒劳地希望该模型将奇迹般地克服垃圾进、垃圾出规则。它不会发生。当我在开发一个新的交易系统时,我首先转向线性模型,只有当我看到明显的优势时,才转向非线性模型。
与复杂的非线性模型相比,在基于预测模型的交易系统中使用线性模型有很多优点。
-
线性模型不太可能过度拟合训练数据。因此,训练偏差被最小化。这个问题在第 121 页开始的部分有更详细的论述。
-
线性模型比许多或大多数非线性模型更容易理解。理解指标值与交易决策的关系是预测模型的一个非常有价值的属性。
-
线性模型的训练速度通常比非线性模型快。在第七章中,我们将探索需要频繁重新训练的强大测试算法,因此快速训练是一个主要优势。
-
很容易将线性模型转换成非线性模型,而不会严重破坏刚刚列出的特性。这将在第 67 页讨论。
-
随着算法复杂性的适度增加,很容易惩罚过度复杂/强大的线性模型,这是一个重要但经常被忽视的正确训练的一部分,称为正则化。
正则化模型概述
正如从第 121 页开始的部分将详细讨论的那样,当模型错误地将随机噪声与真实的、可重复的模式混为一谈时,预测模型的设计和训练中就会出现一个常见而严重的问题。这叫做过拟合。因为根据定义,噪声不会重复,一个过度拟合的模型在投入使用时会表现不佳。
普通的线性模型比大多数非线性模型更不容易过度拟合,尤其是那些极其复杂和强大的模型。但是,即使普通的线性模型也可能过度拟合训练数据,这通常是因为使用了太多的预测器。至少有两种常见且适度有效的方法来处理由于预测器数量过多而导致的过拟合问题。
-
减少预测器的数量:最常用的方法是向前逐步选择。选择一个最有效的预测值。然后,在第一个预测因子存在的情况下,选择一个增加最大预测能力的预测因子。然后选择第三个,依此类推。这种方法的一个严重问题是很难找到第一个单独的预测器。在大多数应用中,是几个预测器的相互作用提供了动力;没有一个单一的预测器做得很好。事实上,可能是 A 和 B 一起做了一个神话般的工作,而其中任何一个都是毫无价值的。但如果 C 恰好表现一般, C 可能会先被选中,结果是 A 和 B 都没有进入比赛,这个优秀的组合也就失去了。还有其他更高级的变异,如逆向选择或子集保留。这些在我的书c++ 中的数据挖掘算法中有详细讨论。然而,每种方法都有自己的问题。
-
将线性回归方程中的系数向零收缩,远离它们的“最佳”最小二乘值。这可能非常有用,因为它让我们保留所有相关的预测器和它们的联合关系信息,同时降低它们学习随机噪声以及更显著的真实模式的能力。但这不是一项微不足道的任务。
有效的模型设计和训练(线性或非线性)的目标是执行这两个修正中的一个或(通常)两个。有许多方法可以做到这一点,其中大多数都涉及到对模型的复杂性施加惩罚,这一过程被称为正则化。区分各种方法的是复杂性的定义和相关惩罚的性质。当应用于线性模型时,此处显示的方法特别强大,因为它可以根据用户的判断进行一种或两种修复,并且它以一种易于理解和快速训练的方式进行。此外,有一个简单的交叉验证方案,让我们优化复杂性降低超参数。它真的很漂亮。
首先我们必须设计一些符号。不失一般性,所有后续开发将假设所有预测值 x 已被标准化为具有零均值和单位方差。这极大地简化了相关的方程以及相关的计算机代码。如果需要,简单的代数运算可以恢复原始变量的系数。
-
N -案例的数量。
-
K -预测变量的数量。
-
xij——案例 i 的预测值 j 。
-
xI-预测向量( K long)为例 i 。这是一个列向量,是表示所有 j 的集合xij的一种方便的表示法。
-
yI——事例 i 的目标变量。
-
β -方程 3-1 表示的线性模型中的 K 系数。这是一个列向量。
-
β0——方程 3-1 表示的线性模型中的标量常数。
-
α -控制正则化的类型的范围从 0 到 1 的常数。
-
λ -控制正则化的度的非负常数。
基本的线性模型认为目标变量的期望值等于预测值的加权组合,加上一个常数。这在方程 3-1 中以矢量形式显示。
(3-1)
我们已经假设预测值已经标准化为零均值和单位方差。在这种情况下,β 0 等于目标的*均值。(这一简单结果的推导在许多标准统计学文本中都有说明。)如果我们假设目标变量也已经标准化,我们可以在开发和编程中获得更多的简单性。我们由此知道β 0 = 0,因此在所有后续工作中可以忽略它。在这种情况下,我们预测目标的标准化值。要获得原始目标的预测值,只需进行非标准化:乘以标准偏差,然后加上*均值。即使有了这个额外的假设,所有原始值的系数和β 0 也很容易用基本代数得到。
找到β权重的最佳值的传统方法是计算最小化均方误差的那些值,即每个预测值yˇI和真实目标值 y i 之间的均方差。但是在我们的正则化版本中,我们给误差增加了一个惩罚项,惩罚是β权重集的函数。这显示在方程式 3-2 中。在这个等式中,乘数 2 可以被吸收到λ或 P α ()中,但以这种方式显示是为了澄清一些中间推导,我们在这里不做介绍,因为我们的重点将放在对模型编程至关重要的等式上。要了解全部细节,请参阅 Friedman、Hastie 和 Tibshirani 的优秀论文“通过坐标下降的广义线性模型的正则化路径”(统计软件杂志,2010 年 1 月)。注意,λ控制惩罚项的影响,如果λ = 0,我们有普通的最小二乘解。将 α 的下标应用于罚函数 P 来阐明它控制罚函数性质的事实。还要注意,方程 3-2 是那篇论文中误差的两倍,没有实际后果。
(3-2)
罚函数 P α 是权重向量的二范数(*方和)和一范数(绝对值和)的加权和,相对权重由 α 参数确定。这显示在方程式 3-3 中。
(3-3)
α 的值对罚函数的性质有深远的影响,该值的范围可以从 0 到 1。这两个极端值有一个共同的名字,许多开发人员都很熟悉。当 α = 0 时,我们有岭回归,当 α = 1 时,我们有套索。这两个极端模型之间的差异最好通过考虑当存在高度相关的预测因子时会发生什么来说明。岭回归倾向于给相关集合中的所有预测因子分配大致相等的权重,从所有预测因子中提取大致相等的贡献。事实上,如果有一组 m 完全相关(归一化后相同)的预测值,岭回归将为每个预测值分配一个β权重,该权重等于 1/ m 乘以在没有其他预测值的情况下分配给其中一个预测值的权重。
套索( α = 1)对一组高度相关的变量做出相反的反应。它倾向于挑选对模型最有用的一个,给它分配一个相对较大的权重,给相关集合的其他成员分配零权重,实际上是将它们从模型中移除,而不是给所有成员分配较小的、相似的权重。
α = 1 的一个潜在问题是,如果碰巧有两个或更多的预测器完全相关,套索就会失去判断哪个是最好的想法,因为它们都同样有用。训练算法在数值上变得不稳定。由于这个原因,除非你确信数据中不存在这种退化,否则如果你想使用套索模型,你应该将 α 设置为非常接* 1 但不完全是 1 的值。这个模型将几乎等同于一个真正的套索,但不会因为完美或接*完美的相关性而不稳定。
在大多数金融交易开发中,有大量的预测器以“墙上的意大利面条”的方式扔向模型,通常最好将 α 设置为 0 到 1 之间的一个值,以达到最佳效果。对于任何固定的λ,零系数(从模型中排除的变量)的数量随着 α 从零到一单调增加。当 α = 0 时,所有变量都包括在内,然后对于更大的 α 值,它们倾向于一个接一个地退出(它们的β权重变为零)。以这种方式,开发者可以设置 α 的值来支持期望的稀疏度。
在将这里描述的模型与普通的线性回归进行比较时,读者应该记住三件事:
-
当我们这样惩罚模型时,我们得到的解不再是最小二乘解。计算的β权重将产生超过普通线性回归的均方误差。在大多数实际应用中,这是一件好事,因为它产生了更好的泛化能力。这就是这种方法的全部意义!然而,从表面上看,这似乎是违反直觉的,好像我们在故意削弱这个模型。但这正是我们正在做的,以减少它错误地学习随机噪声的能力。
-
我们应该对这个模型如何处理强相关的预测值感到特别高兴。普通的线性回归通常对这种情况有可怕的反应,将一些系数放大到巨大的正值,然后通过将其他系数放大到巨大的负值来进行补偿,使一个相关变量与另一个相关变量保持微妙的*衡关系。
-
这种正则化模型通常找到候选预测值的一个子集,通常是一个小的子集,就像普通逐步包含的情况一样。但它的方法非常不同,远远优于逐步包含。后者采取有序的全有或全无的方法;一旦一个变量被包含,它将永远存在。但是正则化的线性模型是逐渐运行的,缓慢地收敛于预测因子的理想子集。在其他变量存在的情况下,变量的值会时好时坏。这使得最终子集更有可能是真正最优的。
保证收敛的β调整
有一个简单的公式,根据该公式,给定训练数据,连同超参数λ和 α ,我们可以有效地计算出调整后的β权重,从而降低等式 3-2 中所示的误差标准。在所有实际条件下,该误差准则具有单个局部最小值,该局部最小值也是全局最小值。因此,即使对于大问题,简单的权重旋转也能保证收敛,通常非常快。在这一节中,我们将介绍这一调整公式,省略了在前面引用的论文中可以找到的许多推导细节。我们将很快看到如何使用这个公式来实现一个高效稳定的训练算法。
首先将模型的残差定义为其预测误差,如方程 3-4 所示。
(3-4)
为每个预测值 j 定义一个我称之为自变量 j 的项,如等式 3-5 所示。这是计算的缓慢部分,因为它需要对所有情况下的乘积求和。定义软阈值算子 S (),如公式 3-6 所示。然后,减少误差标准的β j 的新值由等式 3-7 给出。
(3-5)
(3-6)
(3-7)
差异案例加权
在某些应用程序中(虽然在市场交易应用程序中并不常见),将一些案例评定为比其他案例更重要,从而指导训练算法更加关注减少重要案例的错误,这可能是有用的。上一节中展示的测试版更新公式很容易修改来实现这一功能。
让 N 个案例权重被表示为wI,其中这些权重总和为 1。等式 3-8 给出了软阈值算子的自变量,等式 3-9 给出了更新后的β权重。
(3-8)
(3-9)
感兴趣的读者最好做一个简单的练习。假设所有权重都等于 1/ N ,那么不存在差分加权。解决方程 3-8 简化为方程 3-5 ,方程 3-9 简化为方程 3-7 的问题。如果没有马上看到(剧透预警!),记住预测因子已经标准化为单位方差了。因此,对于每个 j ,所有情况下的总和xij的*方等于 N 。
协方差更新的快速计算
如果情况( N )比预测值( K )多得多,这是市场交易中的常见情况,那么有一个替代公式来计算β权重更新,它比“原始”公式 3-5 和 3-8 快得多。基本公式由公式 3-10 给出。
(3-10)
如果不使用差分案例加权,则所有 j 、茵纳、 j 、欣纳、JK、 3-12 由公式 3-11 给出 Xss 、j、 = 1。这些表达式的推导在前面引用的论文中给出。
(3-11)
(3-12)
如果我们使用差分加权,我们需要方程 3-13 、 3-14 和 3-15 。这些推导在引用的论文中没有给出,但是它们很容易从方程 10 开始并遵循非加权步骤得到,记住预测值是标准化的。
(3-13)
(3-14)
(3-15)
注意,等式 3-13 到 3-15 仅取决于训练数据和权重,因此它们可以在训练开始时仅计算一次。而且方程 3-10 ,每次迭代都必须求值,只涉及对 K 项求和,而不是对 N 项求和,当 K < < N 时,节省的时间是巨大的。
预备代码
我们以一些片段开始展示代码,这些片段说明了我们如何准备训练模型的关键部分。封装该模型及其所有训练算法的整个CoordinateDescent
类的完整源代码在 CDMODEL.CPP 文件中。
程序员将首先调用构造函数,如下所示。这里我们将跳过它的代码,因为它只涉及内存分配和其他简单的内务处理。暂时忽略nl
参数;这个后面会讨论。其他参数不言自明。
CoordinateDescent::CoordinateDescent (
int nv , // Number of predictor variables
int nc , // Number of cases we will be training
int wtd , // Will we be using case weights? 1=Yes, 0=No
int cu , // Use fast covariance updates rather than slow naive method
int nl // Number of lambdas we will be using in training
)
在我们构造了一个CoordinateDescent
对象之后,必须调用一个成员函数来输入训练数据,计算一些准备中的东西。
void CoordinateDescent::get_data (
int istart , // Starting index in full database for getting nc cases of training set
int n , // Number of cases in full database (we wrap back to the start if needed)
double *xx , // Full database (n rows, nvars columns)
double *yy , // Predicted variable vector, n long
double *ww // Case weights (n long) or NULL if no weighting
)
在这个调用中,我们可以在数据集中指定一个起始索引(用于预测值、目标值和可选权重)。构造函数调用(nc
)中指定的事例数将从索引istart
开始从xx
、yy
和ww
(如果使用)中获取。如果在获得nc
病例之前到达数据的结尾,它会绕到数据集的开头。我们稍后会看到这种包装是如何有用的。
get_data()
例程首先将预测值和目标值保存在私有数组中,然后通过减去*均值并除以标准偏差来标准化它们。这里没有显示这些简单的操作。如果要使用差分加权,权重被缩放为总和为 1(因此用户无需担心这一点),并且使用等式 3-13 计算XSSvec
。与重量相关的代码如下:
if (w != NULL) {
sum = 0.0 ;
for (icase=0 ; icase<ncases ; icase++) {
k = (icase + istart) % n ; // Wrap to start if needed
w[icase] = ww[k] ;
sum += w[icase] ;
}
for (icase=0 ; icase<ncases ; icase++)
w[icase] /= sum ;
for (ivar=0 ; ivar<nvars ; ivar++) {
xptr = x + ivar ;
sum = 0.0 ;
for (icase=0 ; icase<ncases ; icase++) // Equation 3-13
sum += w[icase] * xptr[icase*nvars] * xptr[icase*nvars] ;
XSSvec[ivar] = sum ;
}
}
如果我们使用快速协方差更新方法,这是当案例多于预测值时的明智做法,我们必须按照上一节所述计算Yinner
和Xinner
。注意Xinner
是一个对称矩阵,但是我们还是保存了整个矩阵。这浪费了非常便宜的存储器,但是更简单的寻址节省了非常昂贵的时间。
在下面的代码中,我们一次处理一个变量。在第一种情况下,通过使用指针xptr
获得当前变量的偏移量,可以简化寻址。此后,我们只需向下跳一格就可以得到这个变量。
for (ivar=0 ; ivar<nvars ; ivar++) {
xptr = x + ivar ;
sum = 0.0 ; // Do Yinner
if (w != NULL) { // Weighted cases
for (icase=0 ; icase<ncases ; icase++)
sum += w[icase] * xptr[icase*nvars] * y[icase] ; // Equation 3-14
Yinner[ivar] = sum ;
}
else {
for (icase=0 ; icase<ncases ; icase++)
sum += xptr[icase*nvars] * y[icase] ; // Equation 3-11
Yinner[ivar] = sum / ncases ;
}
// Do Xinner
if (w != NULL) { // Weighted
for (jvar=0 ; jvar<nvars ; jvar++) {
if (jvar == ivar)
Xinner[ivar*nvars+jvar] = XSSvec[ivar] ; // Already computed, so use it
else if (jvar < ivar) // Matrix is symmetric, so just copy
Xinner[ivar*nvars+jvar] = Xinner[jvar*nvars+ivar] ;
else {
sum = 0.0 ;
for (icase=0 ; icase<ncases ; icase++)
sum += w[icase] * xptr[icase*nvars] * x[icase*nvars+jvar] ; // Eq (3-15)
Xinner[ivar*nvars+jvar] = sum ;
}
}
} // If w
else { // Unweighted
for (jvar=0 ; jvar<nvars ; jvar++) {
if (jvar == ivar)
Xinner[ivar*nvars+jvar] = 1.0 ; // Recall that X is standardized
else if (jvar < ivar) // Matrix is symmetric, so just copy
Xinner[ivar*nvars+jvar] = Xinner[jvar*nvars+ivar] ;
else {
sum = 0.0 ;
for (icase=0 ; icase<ncases ; icase++)
sum += xptr[icase*nvars] * x[icase*nvars+jvar] ; // Equation 3-12
Xinner[ivar*nvars+jvar] = sum / ncases ;
}
}
} // // Else not weighted
} // For ivar
Beta 优化流程概述
在前面的几节中,我们看到了对于任何选择的β权重,我们如何计算一个修正值,该值将误差标准降低到唯一的全局最小值。因此,在最简单的层面上,我们可以轮换权重,依次调整每个权重,直到获得令人满意的收敛。但是我们可以更智能地做这件事,利用这样一个事实:一旦β权重变为零,它在后续迭代中倾向于保持为零。这里显示了训练算法的概要,解释如下。稍后会出现更详细的代码。
do_active_only = 0 ; // Begin with a complete pass
for (iter=0 ; iter<maxits ; iter++) { // Main iteration loop; maxits is for safety only
active_set_changed = 0 ; // Did any betas go to/from 0.0?
for (ivar=0 ; ivar<nvars ; ivar++) { // Descend on this beta
if (do_active_only && beta[ivar] == 0.0)
continue ;
[ Compute correction ]
if (correction != 0.0) { // Did this beta change?
if ((beta[ivar]==0.0 && new_beta != 0.0) || (beta[ivar] != 0.0 && new_beta==0.0))
active_set_changed = 1 ;
}
} // For all variables; a complete pass
converged = [ Convergence test ] ;
if (do_active_only) { // Are we iterating on the active set only?
if (converged) // If we converged
do_active_only = 0 ; // We now do a complete pass
}
else { // We just did a complete pass (all variables)
if (converged && ! active_set_changed)
break ;
do_active_only = 1 ; // We now do an active-only pass
}
} // Outer loop iterations
这种训练算法的基本思想是,我们可以通过将大部分精力集中在那些非零的β权重(称为活动集)上来节省大量计算工作。粗略地说,我们通过所有的预测,调整每个β权重。过了这一关之后,经常会出现这样的情况,一些测试,也许是许多测试,是零。因此,我们进行额外的传递,只调整那些非零的(活动集),直到获得收敛。当我们收敛时,我们会遍历所有预测值,以防修正的β权重导致一个或多个β变为零或从零开始。如果没有这样的变化发生,并且我们通过了收敛测试,我们就完成了。否则,我们将返回到仅在活动集中循环。
我们从do_active_only
假开始,以便调整所有预测值。为了安全起见,主迭代循环受到maxits
的限制,尽管实际上这个限制永远不会达到。我们使用active_set_changed
来标记是否有任何 beta 权重变为零或从零开始。
ivar
循环对所有预测器进行一次遍历。如果我们只做活动集,而这个β是零,跳过它。否则,我们计算一个修正的β。如果 beta 发生了变化,我们可以看到变化是从零开始还是到零,如果是这样,我们可以通过设置active_set_changed
标志来记录。
在我们通过预测器之后,我们执行一个收敛测试。如果我们一直只检查活动集,并且如果我们已经收敛,我们重置do_active_only
以便下一次我们检查所有预测器。
另一方面,如果我们的最后一遍是对所有预测值的完整检查,获得了收敛,并且活动集没有改变,那么我们就都完成了。否则,我们设置do_active_only
标志,以便我们回到只关注活动集。
这种专注于活动集的奇特算法只有在有大量零 beta 权重时才有优势。但是,在使用这种模型的应用程序中,情况往往就是这样。此外,在贝塔系数很少或没有为零的情况下,惩罚很少或没有,所以我们不妨使用花哨的版本。
Beta 优化代码
前一节给出了 beta 优化算法的概要,省略了细节,以便过程的基本逻辑将是清楚的。在本节中,我们将详细介绍整个优化代码。它的名称如下:
void CoordinateDescent::core_train (
double alpha , // User-specified alpha (0-1) (0 problem for descending lambda)
double lambda , // Can be user-specified, but usually from lambda_train()
int maxits , // Maximum iterations, for safety only
double eps , // Convergence criterion, typically 1.e-5 or so
int fast_test , // Convergence via max beta change vs explained variance?
int warm_start // Start from existing beta, rather than zero?
)
alpha
( α )和lambda
(λ)参数已经见过很多次了。我们使用maxits
只是为了限制迭代次数,以防止意外挂起。实际上,它会被设置得非常大。eps
参数控制收敛信号发出前结果的精确度。fast_test
参数控制使用两个收敛测试(稍后描述)中的哪一个。最后,warm_start
允许从 beta 权重的当前值开始训练,而不是从零开始(默认)。该例程从一些初始化开始。
S_threshold = alpha * lambda ; // Threshold for the soft-thresholding S() of Eq (3-6)
do_active_only = 0 ; // Begin with a complete pass
prior_crit = 1.0e60 ; // For convergence test
if (warm_start) { // Pick up with current betas?
if (! covar_updates) { // If not using covar updates, must recompute residuals
for (icase=0 ; icase<ncases ; icase++) {
xptr = x + icase * nvars ;
sum = 0.0 ;
for (ivar=0 ; ivar<nvars ; ivar++)
sum += beta[ivar] * xptr[ivar] ;
resid[icase] = y[icase] - sum ;
}
}
}
else { // Not warm start, so initial betas are all zero
for (i=0 ; i<nvars ; i++)
beta[i] = 0.0 ;
for (i=0 ; i<ncases ; i++) // Initial residuals are just the Y variable
resid[i] = y[i] ;
}
先前初始化代码最值得注意的方面是,如果我们正在进行热启动,并且我们没有使用快速协方差更新方法,那么我们必须重新计算残差。回想一下,方程 3-7 和 3-9 的简单更新方法需要残差。当然,如果我们从零开始所有的β权重,那么所有的预测也是零,残差只是目标。
随着迭代的进展,我们将计算解释的目标方差的分数,以启迪用户。为此,我们需要目标的均方差,如果用户选择了按重要性对案例进行加权,则需要对均方差进行适当加权。下面的代码计算这个数量:
if (w != NULL) { // We need weighted squares to evaluate explained variance
YmeanSquare = 0.0 ;
for (i=0 ; i<ncases ; i++)
YmeanSquare += w[i] * y[i] * y[i] ;
}
else
YmeanSquare = 1.0 ; // The target has been normalized to unit variance
我们现在开始主要的外部循环,迭代直到获得收敛。迭代限制maxits
应该设置得非常大(几千或更多)以便它不会导致过早退出;只是“挂保险”。我们重置标志,该标志将指示活动集是否改变,并且我们将使用max_change
来跟踪收敛测试的最大 beta 变化。
for (iter=0 ; iter<maxits ; iter++) {
active_set_changed = 0 ; // Did any betas go to/from 0.0?
max_change = 0.0 ; // For fast convergence test
对所有预测值进行一次遍历的循环现在开始。如果我们只处理活动集(非零 beta ),并且这个 beta 为零,跳过它。加权情况下的方程 3-9 和未加权情况下的方程 3-7 将需要分母中的update_factor
,因此现在计算它。回想一下XSSvec[]
是通过等式 3-13 计算出来的。
for (ivar=0 ; ivar<nvars ; ivar++) { // Descend on this beta
if (do_active_only && beta[ivar] == 0.0)
continue ;
// Denominator in update
if (w != NULL) // Weighted?
Xss = XSSvec[ivar] ;
else
Xss = 1 ; // X was standardized
update_factor = Xss + lambda * (1.0 - alpha) ;
我们计算软阈值函数的自变量。有三种可能。要么我们使用快速协方差更新方法,要么我们使用具有不同情况加权的朴素方法,要么我们使用具有相等加权的朴素方法。我们不必在这里将协方差更新方法分为有权重和无权重,因为在计算Xss
、Xinner
和Yinner
时已经考虑了任何权重,如第 42 页所示。
if (covar_updates) { // Any sensible user will specify this unless ncases < nvars
sum = 0.0 ;
for (kvar=0 ; kvar<nvars ; kvar++)
sum += Xinner[ivar*nvars+kvar] * beta[kvar] ;
residual_sum = Yinner[ivar] - sum ;
argument = residual_sum + Xss * beta[ivar] ; // Equation 3-10
}
else if (w != NULL) { // Use slow naive formula (okay if ncases < nvars)
argument = 0.0 ;
xptr = x + ivar ; // Point to column of this variable
for (icase=0 ; icase<ncases ; icase++) // Equation 3-8
argument += w[icase] *
xptr[icase*nvars] * (resid[icase] + beta[ivar] * xptr[icase*nvars]) ;
}
else { // Use slow naive formula (okay if ncases < nvars)
residual_sum = 0.0 ;
xptr = x + ivar ; // Point to column of this variable
for (icase=0 ; icase<ncases ; icase++)
residual_sum += xptr[icase*nvars] * resid[icase] ; // X_ij * RESID_i
residual_sum /= ncases ;
argument = residual_sum + beta[ivar] ; // Equation 3-5
}
我们刚刚计算了软阈值函数的自变量,方程 3-6 。应用该函数,并使用公式 3-7 或公式 3-9 计算该 beta 的新值。不久前,我们计算出update_factor
是这些方程中的分母。
if (argument > 0.0 && S_threshold < argument)
new_beta = (argument - S_threshold) / update_factor ;
else if (argument < 0.0 && S_threshold < -argument)
new_beta = (argument + S_threshold) / update_factor ;
else
new_beta = 0.0 ;
修正量是新的 beta 值和旧值之间的差值。记录这一过程中的最大变化,因为我们可能会用它来进行收敛测试。如果我们使用缓慢的朴素更新方法,我们也将使用这种校正来快速重新计算残差,这是朴素方法所需要的。
correction = new_beta - beta[ivar] ;
if (fabs(correction) > max_change)
max_change = fabs(correction) ; // Used for fast convergence test
if (correction != 0.0) { // Did this beta change?
if (! covar_updates) { // Must we update the residual vector?
xptr = x + ivar ; // Point to column of this variable
for (icase=0 ; icase<ncases ; icase++) // Update residual per this new beta
resid[icase] -= correction * xptr[icase*nvars] ;
}
if ((beta[ivar]==0.0 && new_beta!=0.0) || (beta[ivar]!=0.0 && new_beta==0.0))
active_set_changed = 1 ;
beta[ivar] = new_beta ;
}
} // For all variables; a complete pass
根据do_active_only
的说法,我们已经完成了一次测试,要么是所有的测试,要么只是活动集。我们现在做收敛测试,无论是快速,简单的版本或慢得多的版本。快速测试仅基于β的最大(所有预测值)变化。但是慢速测试更复杂。
如果我们使用快速协方差更新方法,我们不需要 beta 更新的残差,所以我们不需要(巨大的!)是时候计算它们了。但是我们需要残差来进行缓慢收敛测试,所以如果到目前为止我们还没有计算它们,我们必须计算它们。使用残差计算(可能加权的)均方误差。
if (fast_test) { // Quick and simple test
if (max_change < eps)
converged = 1 ;
else
converged = 0 ;
}
else { // Slow test (change in explained variance) which requires residual
if (covar_updates) { // We have until now avoided computing residuals
for (icase=0 ; icase<ncases ; icase++) {
xptr = x + icase * nvars ;
sum = 0.0 ;
for (ivar=0 ; ivar<nvars ; ivar++)
sum += beta[ivar] * xptr[ivar] ; // Cumulate predicted value
resid[icase] = y[icase] - sum ; // Residual = true - predicted
}
}
sum = 0.0 ; // Will cumulate squared error for convergence test
if (w != NULL) { // Are the errors of each case weighted differently?
for (icase=0 ; icase<ncases ; icase++)
sum += w[icase] * resid[icase] * resid[icase] ;
crit = sum ;
}
else {
for (i=0 ; i<ncases ; i++)
sum += resid[i] * resid[i] ;
crit = sum / ncases ; // MSE component of optimization criterion
}
模型的基本质量度量是模型解释的目标方差的分数。这是通过从目标的均方(方差)中减去刚刚计算的均方误差来计算的,以获得所解释的方差的量。将其除以目标均方差,得到模型所解释的目标方差的分数。这是严格用于可选的用户启发;它在优化算法中不起作用。
使用第 39 页的等式 3-3 计算正则化罚分,然后将该罚分与均方误差相加,得到我们最小化的标准,如第 38 页的等式 3-2 所示。
这个“慢”收敛标准是基于优化标准中从一次迭代到下一次迭代的变化。如果变化很小(其中“小”是由用户指定的eps
定义的),那么我们被认为已经收敛。
explained_variance = (YmeanSquare - crit) / YmeanSquare ;
penalty = 0.0 ;
for (i=0 ; i<nvars ; i++)
penalty += 0.5 * (1.0 - alpha) * beta[i] * beta[i] + alpha * fabs (beta[i]) ;
penalty *= 2.0 * lambda ; // Regularization component of optimization criterion
crit += penalty ; // This is what we are minimizing
if (prior_crit - crit < eps)
converged = 1 ;
else
converged = 0 ;
prior_crit = crit ;
}
现在,我们可以使用上一节中描述的控制逻辑完成外循环,在仅活动集和完整预测器通道之间交替。
if (do_active_only) { // Are we iterating on the active set only?
if (converged) // If we converged
do_active_only = 0 ; // We now do a complete pass
}
else { // We just did a complete pass (all variables)
if (converged && ! active_set_changed)
break ;
do_active_only = 1 ; // We now do an active-only pass
}
} // Outer loop iterations
我们基本上完成了。为了启发用户,我们计算并保存由模型解释的目标方差的分数。如果我们做了快速收敛测试和协方差更新,我们必须计算残差来得到解释的方差。这两个选项不需要常规的残差计算,所以我们目前没有残差。
if (fast_test && covar_updates) { // Residuals have not been maintained?
for (icase=0 ; icase<ncases ; icase++) {
xptr = x + icase * nvars ;
sum = 0.0 ;
for (ivar=0 ; ivar<nvars ; ivar++)
sum += beta[ivar] * xptr[ivar] ;
resid[icase] = y[icase] - sum ;
}
}
sum = 0.0 ;
if (w != NULL) { // Error term of each case weighted differentially?
for (i=0 ; i<ncases ; i++)
sum += w[i] * resid[i] * resid[i] ;
crit = sum ;
}
else {
for (i=0 ; i<ncases ; i++)
sum += resid[i] * resid[i] ;
crit = sum / ncases ; // MSE component of optimization criterion
}
explained = (YmeanSquare - crit) / YmeanSquare ;
沿λ路径下行
如同通常具有超参数的模型的情况一样,为正则化强度λ(λ)选择有效值可能并不简单。在下一节中,我们将探索一种自动选择好值的好方法。在本节中,我们将介绍一个工具,该工具将由自动化例程调用,并且还可以用于帮助手动选择一个好的 lambda。
考虑一下,如果 lambda 很大,那么任何非零 beta 的惩罚都将很大,以至于所有 beta 权重都将被强制为零。(如果α正好为零,情况可能不是这样,所以从现在开始我们将假设α为 0。)这个模型显然是零解释方差。相反,如果λ = 0,那么我们有普通的线性回归,它具有最小可能的均方误差或最大可能的解释方差。因此,我们可以从一个较大的λ开始,训练模型,稍微降低λ,然后再次训练,以此类推,直到λ很小,几乎为零。我们通常会看到非零贝塔的数量稳步增加,以及解释方差。即使对于相同数量的非零贝塔,解释的方差也会随着λ的减小而增加。如果我们打印一张图表,显示非零贝塔值的数量和解释的方差作为λ的函数,我们也许能够对λ作出明智的选择。
这种方法有一个有趣的额外好处,即使我们事先知道我们想要使用的λ。这种方法增加了训练算法已经相当好的稳定性,而在速度方面没有太大的代价。事实上,我们可以这样训练更快。我们所做的是从一个大的 lambda 开始,这个 lambda 只给我们一个或很少几个有效的预测器。那种简单的模型会训练得很快。然后,当我们稍微降低 lambda 时,我们不是从头开始,而是热启动,从现有的 betas 开始迭代。所以,每次我们用稍小的λ重新开始训练,我们都是从已经非常接*正确的 betas 开始。因此,收敛将很快获得。
很容易为下降找到一个好的起始λ,最小的λ使得所有的贝塔为零。整个过程从所有 betas 为零开始。查看方程 3-7 ,以及自变量和软阈值算子的两个先前方程。对于差分加权的情况,在下一节中查看它们的类似物。回想一下,当所有贝塔系数都为零时,残差等于目标值, y 。根据软阈值函数的定义,很明显,如果未加权情况下的等式 3-16 或差分加权情况下的等式 3-17 为真,则 β 将保持为零。
(3-16)
(3-17)
将这些方程的两边除以α得到任何预测因子的阈值λ,如果我们找到所有预测因子中的最大λ,我们就有了起始λ。下面是执行此操作的代码:
double CoordinateDescent::get_lambda_thresh ( double alpha )
{
int ivar, icase ;
double thresh, sum, *xptr ;
thresh = 0.0 ;
for (ivar=0 ; ivar<nvars ; ivar++) {
xptr = x + ivar ;
sum = 0.0 ;
if (w != NULL) {
for (icase=0 ; icase<ncases ; icase++) // Left side of Equation 3-17
sum += w[icase] * xptr[icase*nvars] * y[icase] ;
}
else {
for (icase=0 ; icase<ncases ; icase++) // Left side of Equation 3-16
sum += xptr[icase*nvars] * y[icase] ;
sum /= ncases ;
}
sum = fabs(sum) ;
if (sum > thresh) // We must cover all predictors
thresh = sum ;
}
return thresh / (alpha + 1.e-60) ; // Solve for lambda; protect from division by zero
}
在 lambda 上下降很简单。需要注意的一点是,我们保存了每个试用 lambda 的 beta 权重,因为我们以后可能需要访问它们。此外,如果调用者设置了print_steps
标志,这个例程将打开一个文本文件并附加结果,以便于用户检查。
我们使用get_lambda_thresh()
找到最小的λ,确保所有的贝塔值保持为零,并稍微减小它以得到我们的起始λ。我们任意地将最小λ设置为该量的 0.001 倍。在构造函数调用中指定了试验次数。代码如下:
void CoordinateDescent::lambda_train (
double alpha , // User-specified alpha, (0,1) (Greater than 0)
int maxits , // Maximum iterations, for safety only
double eps , // Convergence criterion, typically 1.e-5 or so
int fast_test , // Convergence via max beta change vs explained variance?
double max_lambda , // Starting lambda, or negative for automatic computation
int print_steps // Print lambda/explained table?
)
{
int ivar, ilambda, n_active ;
double lambda, min_lambda, lambda_factor ;
FILE *fp_results ;
if (print_steps) {
fopen_s ( &fp_results , "CDtest.LOG" , "at" ) ;
fprintf ( fp_results , "\n\nDescending lambda training..." ) ;
fclose ( fp_results ) ;
}
if (n_lambda <= 1) // Nonsensical parameter from caller
ireturn ;
/*
Compute the minimum lambda for which all beta weights remain at zero
This (slightly decreased) will be the lambda from which we start our descent.
*/
if (max_lambda <= 0.0)
max_lambda = 0.999 * get_lambda_thresh ( alpha ) ;
min_lambda = 0.001 * max_lambda ;
lambda_factor = exp ( log ( min_lambda / max_lambda ) / (n_lambda-1) ) ;
/*
Repeatedly train with decreasing lambdas
*/
if (print_steps) {
fopen_s ( &fp_results , "CDtest.LOG" , "at" ) ;
fprintf ( fp_results , "\nLambda n_active Explained" ) ;
}
lambda = max_lambda ;
for (ilambda=0 ; ilambda<n_lambda ; ilambda++) {
lambdas[ilambda] = lambda ; // Save in case we want to use later
core_train ( alpha , lambda , maxits , eps , fast_test , ilambda ) ;
for (ivar=0 ; ivar<nvars ; ivar++) // Save these in case we want them later
lambda_beta[ilambda*nvars+ivar] = beta[ivar] ;
if (print_steps) {
n_active = 0 ; // Count active predictors for user’s edification
for (ivar=0 ; ivar<nvars ; ivar++) {
if (fabs(beta[ivar]) > 0.0)
++n_active ;
}
fprintf ( fp_results , "\n%8.4lf %4d %12.4lf", lambda, n_active, explained ) ;
}
lambda *= lambda_factor ;
}
if (print_steps)
fclose ( fp_results ) ;
}
通过交叉验证优化 Lambda
如果不是最流行的,也是最流行的优化模型超参数的方法之一是交叉验证,所以这就是我们在这里要做的。原理很简单。对于每个折叠,我们调用lambda_train()
来测试一组递减的λ,保存每个试验λ的β系数。然后,我们计算每个试验λ的样本外解释方差,并累积该量。当所有折叠完成后,我们检查合并的 OOS 性能,并选择哪个λ给出最佳 OOS 性能。不过,有一些事情需要注意,所以我们将把这段代码分成几个单独的部分,分别进行解释。下面是调用参数列表:
double cv_train (
int n , // Number of cases in full database
int nvars , // Number of variables (columns in database)
int nfolds , // Number of folds
double *xx , // Full database (n rows, nvars columns)
double *yy , // Predicted variable vector, n long
double *ww , // Optional weights, n long, or NULL if no weighting
double *lambdas , // Returns lambdas tested by lambda_train()
double *lambda_OOS , // Returns OOS explained for each of above lambdas
int covar_updates , // Does user want (usually faster) covariance update method?
int n_lambda , // This many lambdas tested by lambda_train() (at least 2)
double alpha , // User-specified alpha, (0,1) (greater than 0)
int maxits , // Maximum iterations, for safety only
double eps , // Convergence criterion, typically 1.e-5 or so
int fast_test // Convergence via max beta change vs explained variance?
)
注意,这不是CoordinateDescent
类的成员;这是一个独立的程序。大多数参数都是不言自明的,以前也见过很多次。最后四个参数和covar_updates
只是传递给核心训练例程。我们必须提供两个向量n_lambdas
long: lambdas
将返回测试的λ值,lambda_OOS
将返回对应于每个测试的λ的 OOS 解释的方差分数。我们应该指定尽可能大的n_lambdas
来进行彻底的测试;50 不是不合理的。大量的 lambda 不会明显降低训练速度,因为使用了热启动,这意味着每次 lambda 降低时,beta 优化都在先前的最优值处开始。这非常快。最后,为了获得最佳精度,折叠次数也应该尽可能多;五个是最低限度,十个是合理的,如果计算机时间允许,更多更好。
我们从一些初始化开始。自然地,我们希望对每个折叠使用相同的降序 lambdas 集合,因此我们使用整个数据集来寻找阈值。如果案例被加权,我们复制归一化的权重用于 OOS 评分。第一次培训将从第一个案例开始,我们还没有做过任何 OOS 案例。我们将累积在lambda_OOS
中解释的方差的分数,因此对于每个试验λ将这个向量初始化为零。我们将累积YsumSquares
中的(可能加权的)目标*方和。
cd = new CoordinateDescent ( nvars , n , (ww != NULL) , covar_updates , n_lambda ) ;
cd->get_data ( 0 , n , xx , yy , ww ) ; // Fetch the training set for this fold
max_lambda = cd->get_lambda_thresh ( alpha ) ;
if (ww != NULL) {
for (icase=0 ; icase<n ; icase++)
work[icase] = cd->w[icase] ;
}
delete cd ;
i_IS = 0 ; // Training data starts at this index in complete database
n_done = 0 ; // Number of cases treated as OOS so far
for (ilambda=0 ; ilambda<n_lambda ; ilambda++)
lambda_OOS[ilambda] = 0.0 ; // Will cumulate across folds here
YsumSquares = 0.0 ; // Will cumulate to compute explained fraction
折叠循环从这里开始。OOS 案例的数量是剩余要做的数量除以剩余折叠的数量。其余情况是样本内的,OOS 集从 IS 集之后开始。
for (ifold=0 ; ifold<nfolds ; ifold++) {
n_OOS = (n - n_done) / (nfolds - ifold) ; // Number of cases in OOS (test set)
n_IS = n - n_OOS ; // Number IS (training set)
i_OOS = (i_IS + n_IS) % n ; // OOS starts at this index
我们现在用这个样本集训练,在 lambda 上下降。这个集合从索引i_IS
开始,如果到达数据集的结尾,它将循环回到开始。
cd = new CoordinateDescent ( nvars , n_IS , (ww != NULL) , covar_updates ,
n_lam bda ) ;
cd->get_data ( i_IS , n , xx , yy , ww ) ; // Fetch the training set for this fold
cd->lambda_train ( alpha , maxits , eps , fast_test , max_lambda , 0 ) ;
训练已经完成,所以我们在 OOS 集上评估性能。下面是代码;下一页是逐步解释:
for (ilambda=0 ; ilambda<n_lambda ; ilambda++) {
lambdas[ilambda] = cd->lambdas[ilambda] ; // This will be the same for all folds
coefs = cd->lambda_beta + ilambda * nvars ;
sum = 0.0 ;
for (icase=0 ; icase<n_OOS ; icase++) {
k = (icase + i_OOS) % n ;
pred = 0.0 ;
for (ivar=0 ; ivar<nvars ; ivar++)
pred += coefs[ivar] * (xx[k*nvars+ivar] - cd->Xmeans[ivar]) / cd->Xscales[ivar] ;
Ynormalized = (yy[k] - cd->Ymean) / cd->Yscale ;
diff = Ynormalized - pred ;
if (ww != NULL) {
if (ilambda == 0)
YsumSquares += work[k] * Ynormalized * Ynormalized ;
sum += work[k] * diff * diff ;
}
else {
if (ilambda == 0)
YsumSquares += Ynormalized * Ynormalized ;
sum += diff * diff ;
}
}
lambda_OOS[ilambda] += sum ; // Cumulate for this fold
} // For ilambda
delete cd ;
n_done += n_OOS ; // Cumulate OOS cases just processed
i_IS = (i_IS + n_OOS) % n ; // Next IS starts at this index
} // For ifold
上一页的代码处理单个折叠的 OOS 集。随着有效的λ下降算法的进展,训练例程保存每个试验λ的β权重。因此,我们遍历 lambda,将每个 lambda 的 betas 放入coefs
。我们将遍历所有 OOS 案例,累计sum
中的误差*方和。
我们循环遍历数据集,当到达末尾时循环回到开始,因此k
是将要测试的 OOS 案例的索引。OOS 情况(目标和所有预测值)必须以与训练数据相同的方式进行归一化,使用相同的*均值和标准差。
这种情况下的误差diff
,是真实值减去预测值。我们累积*方误差,如果使用了不同的权重,则乘以用户指定的案例权重。我们同时累积归一化目标的*方和。这必须只做一次,因为它当然对所有试验 lambdas 都是一样的。当 case 循环完成时,我们将误差总和加到被测试的 lambda 的总和上。这个向量将累积所有折叠的总和。lambda 循环完成后,我们删除这个文件夹的CoordinateDescent
对象,并前进到下一个文件夹。
剩下要做的就是计算每个 lambda 的 OOS 解释方差分数,并将表现最好的 lambda 返回给调用者。目标*方和减去误差*方和得到解释的*方和。将其除以目标 SS,得到解释方差的分数。
best = -1.e60 ;
for (ilambda=0 ; ilambda<n_lambda ; ilambda++) {
lambda_OOS[ilambda] = (YsumSquares - lambda_OOS[ilambda]) / YsumSquares ;
if (lambda_OOS[ilambda] > best) {
best = lambda_OOS[ilambda] ;
ibest = ilambda ;
}
}
return lambdas[ibest] ;
}
CD_MA 程序
文件 CD_MA。CPP 包含一个程序,该程序读取市场价格文件,根据移动*均线振荡器计算大量指标,并使用CoordinateDescent
正则化线性模型来找到指标的最佳子集,以预测第二天的(对数)价格变化。历史文件末尾的一年的市场数据将作为测试集使用。
使用以下命令调用该程序:
CD_MA Lookback_inc N_long N_short Alpha Filename
让我们来分解这个命令:
-
Lookback_inc
:长期回看将从这个数量的棒线(包括当前棒线)开始回看。后续的长期回顾将以此数量递增。例如,如果指定为 3,则长期回看将为 3、6、9,.... -
这么多的长期回顾将会被采用。最大长期回顾将是
Lookback_inc * N_long
。 -
这么多的短期回顾将会被采用。它们是当前的长期回顾时间 i 然后除以
N_short+1
,对于从 1 到N_short
的 i ,被截断成整数。注意,当当前长期回顾小于N_short+1
时,将会有多个相同的短期回顾值,从而产生完全相关的预测值。指标总数为N_long
*N_short
。 -
Alpha
:控制正则化类型所需的 alpha。如果指定小于或等于零,lambda 将被设置为零,产生普通线性回归(无正则化)。它决不能大于或等于 1。 -
Filename
:格式为YYYYMMDD Open High Low Close
的市场历史文件。
将打印两个表格。第一部分显示了选择最佳λ所涉及的计算。此表中的左栏列出了试用的 lambdas。右栏显示解释方差的相应样本外分数。
第二个表列出了β系数。每一行对应一个长期回顾,回顾打印在每一行的开头。每根柱对应一个短期回看。这些回看不会被打印,因为它们随每行而变化。它们可以用前页的公式很容易地计算出来。精确为零的系数(通常但不总是因为训练算法将它们从模型中移除)用虚线表示。
图 3-1 显示了当λ= 0,无正则化时,为 OEX 产生的β系数表。这实际上等同于普通的线性回归。图 3-2 显示 alpha=0.1 时的结果,图 3-3 针对 alpha=0.9。讨论如下。
图 3-3
阿尔法=0.9
图 3-2
阿尔法=0.1
图 3-1
λ= 0(无正则化)
这次运行使用 OEX S&P 100 指数作为它的市场历史。回望增量为 2,有 30 次长期回望和 10 次短期回望。
-
回想一下本节第一页的讨论,对于小于短期回顾数加 1 的长期回顾,一些短期回顾必须重复,这意味着一些指标是其他指标的精确副本。
-
这种重复使得普通的线性回归变得不可能,因为一些权重是未定义的。将需要诸如奇异值分解的特殊技术。这里 lambda=0 的算法处理得很好,甚至有效地消除了一些重复。但是绝大多数指标都参与了这个模型。
-
因为当λ= 0 时,没有正则化,这是完全最小二乘拟合。这意味着解释方差的样本内部分应该是最大可能值,事实上我们看到了这种情况,解释了 1.63%的目标方差。
-
由于大量的指标参与(没有正规化),我们预计会看到 OOS 表现不佳。是的,这是三次测试中得分最差的一次。
-
当我们应用 alpha=0.1 的正则化(*似岭回归)时,样本内解释方差下降,但 OOS 性能飙升至最佳。
-
在 alpha=0.1 的情况下,我们看到重复的指示符接收到相同的 beta 系数,如预期的那样。
-
当 alpha=0.9(接*套索)时,模型会最小化保留的指标数量,以尝试使模型尽可能简单,即使以牺牲性能为代价。我们看到这种情况发生,甚至选择的指标也发生了变化。OOS 业绩暴跌,意味着该模型被迫放弃一些有用的指标。
-
正则化的模型都是负系数,说明这个交易系统是均值回归系统,不是趋势跟随者!
使线性模型变得非线性
尽管线性模型往往比非线性模型更受青睐,但有时我们的两个或更多指标在与目标的关系中存在不可避免的非线性相互作用。要知道,一个指标仅仅与目标有一个非线性关系,就其本身而言,通常不是问题。我们可以通过某种方式改变指标,使其与目标之间的关系变得基本上是线性的。至少尝试一下总是件好事。当然,也可能发生这样的情况,我们只是怀疑一个单独的非线性关系,但我们无法证明它足以能够合理地转换指标。但是在绝大多数情况下,当指标在与目标的联合关系方面以非线性方式相互作用时,线性模型就会失效。在这种情况下,我们别无选择,只能放弃严格的线性模型。
但是并没有失去一切。线性模型的优势,尤其是正则化模型的优势(对其工作原理的简单理解、快速训练、较低的过拟合可能性)如此之大,以至于值得以适度非线性的方式转换指标及其相互作用,并将这些新值应用于正则化线性模型。我们几乎从来不想采用如此极端的措施,以至于交易决策的界限到处游走,为了抓住每一个错误的训练案例而扭曲。但是有一种简单的方法来应用适度的非线性变换,允许我们以温和的非线性方式使用正则化的线性模型。
自然,我们可以用一个或多个原始预测值的一个或多个非线性函数来补充模型的预测值。如果我们有选择某些特定函数的理论理由,我们当然应该这样做。但那种情况很少见。最常见和最有效的一般程序是使用低次多项式,我将很快讨论两个特殊的扭曲。总体思路是这样的:我们选择低学位,一般是二级,很少是三级。此外,选择一个子集的预测,我们希望允许非线性相互作用。这可能是所有的预测因素,尽管当我们包含更多的预测因素时,事情会变得更糟。然后在选定的程度上,用它们的每一种可能的组合来补充原始预测值。
例如,假设我们有三个预测值,我们希望考虑非线性。称他们为 A、B 和 C 。还假设我们想允许二级,最常见的选择。那么我们发给模型的预测器是 A 、 B 、 C 、 A 2 、 B 2 、 C 2 、 AB 、 AC 、 BC 。如果我们决定向上移动到第三级,附加的预测因子是 A 3 、 B 3 、 C 3 、A2B、A2C、B 显而易见,增加非线性集合中预测因子的数量,或者增加多项式的次数,会导致新预测因子数量的爆炸性增长。
当使用多项式展开时,有两件事应该做。这两者都不是数学上必需的,但如果我们要防止硬件浮点不准确性,以及提高大多数模型训练算法的速度和稳定性,这两者都很重要。首先,我们必须确保转换后的指标具有大约负一比一的自然范围。如果做到了这一点,所有的多项式变换值都具有相同的自然范围。如果我们的原始指标没有这个范围,至少*似地,我们应该从理论考虑或从大的代表性集合的检查中找到它们的真实自然范围,最小到最大。则 X 的量程调整值为 2 (??)X–Min)/(Max–Min*)–1。
我们应该采取的另一个行动只有在我们达到第三级(或者,但愿不会如此,更高级)时才需要。问题是,即使有范围调整, X 和 X 3 可以有足够的相关性,稍微妨碍一些训练算法。它很少是严重的,并且将要描述的技术可能会被一些人认为是过度的,但是它是一个具有良好回报的廉价投资。不使用X3,而是使用 0.5 (5X3–3X)。这仍然是一个范围为负一比一的三次多项式,它将允许与X*3 相同的有效非线性,但它通常与 X 的相关性要小得多,因此将由许多训练算法更有效地处理。你不会有任何损失,而且可能会有很大收获。
超越三级几乎总是毫无意义的。如果你有那么多的非线性,就用非线性模型。但是如果出于某种原因你坚持的话,查查勒让德多项式并用它们来表示高次项。
差分进化:一种通用的非线性优化器
无论你的交易系统是基于非线性预测模型还是传统的算法(基于规则)系统,你都需要一个快速稳定的方法来优化你选择的性能标准。在大多数情况下,优化算法的选择涉及到一个重要的权衡。一个多变量函数通常有几个(也许很多!)局部 optima。最快的优化者是爬山者,迅速爬到最*的山顶,不管那个特定的山是不是最好的。更有可能在众多山顶中找到最佳山顶的优化者比简单的爬山者要慢得多。那么,该怎么办呢?
幸运的是,有一种算法是两个极端之间的一个很好的妥协。在众多山顶中,它找到最好的,或者至少接*最好的山顶的可能性相对较高,但它的速度也相当快。这种算法是一种特殊的遗传或进化优化,称为差分进化。我不会在这里提供任何参考,因为互联网上充满了例子和讨论。相反,我将把重点放在这个算法的一个高度调整的变体上,我已经在我自己的工作中使用了多年,并且我发现它是一个可靠的执行者。
像所有的进化算法一样,它从一群个体开始,每个个体都是交易系统的一个完全指定的参数集。然后,它遍历整个种群,以一种很有可能产生优于双亲的个体的方式组合种群中不同成员的品质。
不同于大多数植物和动物的繁殖,差异进化需要四个个体才能产生一个孩子。其中一个被称为父代 1 ,是确定性选择的。另外三个父母 2 、差异 1 和差异 2 是随机选择的。两个差值决定了一个方向,并且 Parent2 在这个方向上被扰动。通过从 Parent1 中选择一些参数以及从受干扰的 Parent2 中选择其他参数来创建新子节点。如图 3-4 和图 3-5 所示。
图 3-5
差异子代
图 3-4
差异进化的一步
图 3-4 显示一阶和二阶微分相减,它们的差乘以一个常数,通常小于 1。这个缩小的差被加到第二个亲本上,和在随机交叉操作中与第一个亲本合并。将这个子代的表现与主要父代的表现进行比较,并为下一代保留优越的个体。
这在图 3-5 中用图形显示了两个变量。两个微分之间的差决定了一个方向,次级母体在这个方向上被扰动。在这个例子中,这个操作被称为突变,尽管这不是一个通用术语。然后水*变量取自第一亲本,垂直变量取自变异的第二亲本。
这个方案有一个重要的性质:它将扰动缩放到参数的自然尺度。假设功能函数在某个方向上有一个狭窄的脊,这是一种常见的情况。那么人口会被吸引到同样的布局。个体(完整的参数集)将在脊的方向上广泛分布,而在垂直方向上被压缩。其结果是,控制次级母体扰动程度的微分差将沿着脊变大,沿着脊变小,这正是我们想要的。
不幸的是,差分进化有一个大多数随机过程共有的弱点:它很快收敛到全局最优附*,但从来没有完全达到精确的最优。这是因为它本质上无法利用函数的本地知识。爬山法在收敛到局部最优方面做得很好,但是它们容易因为错过全局最优而失败。所以,我的方法是混合的,主要实现差异进化,但偶尔对单个个体执行爬山步骤。这极大地加速了收敛,同时对算法的全局影响可以忽略,因为该操作在任何时候都保持在单个个体的吸引域内。
此处显示了下一页的算法概述。
for (ind=0 ; ind<popsize+overinit ; ind++) { // Generate the basis population
Generate a random parameter vector
value = performance of this random vector
If this individual fails to meet a minimum requirement {
--ind ; // Skip it entirely
continue ;
}
if (ind >= popsize) { // If we finished population, now doing overinit
Find the worst individual in population (popsize individuals)
if (value > worst)
Replace the worst with this new individual
} // If doing overinit
} // For all individuals (population and overinit)
for (generation=1 ; ; generation++) {
for (ind=0 ; ind<popsize ; ind++) { // Generate all children
parent1 = individual 'ind'
Generate three different random indices for parent2 and the two differentials:
parent2, diff1, diff2
for all variables j { // This is the mutation and crossover step
with small probability
trial[j] = parent2[j] + constant * (diff1[j] - diff2[j]) ;
else
trial[j] = parent1[j] ;
}
value = performance of trial parameter set
if (value > parent1's performance)
replace parent1 with trial parameter set
Optionally pick one variable in one individual (favoring the best so far)
and find the optimal value of that variable
} // Create all children in this generation
} // For all generations
Return the best individual in the final population
第一步是生成一个初始群体,这是由这段伪代码中的第一个循环完成的。用户指定群体中的个体数量popsize
。传统算法不包括过度初始化,测试overinit
额外的个体。我发现,将overinit
设置为大约popsize
会以相对较小的额外成本产生一个具有更快收敛速度和更好全局代表性的显著更优的初始种群。
这个群体生成循环创建一个随机个体(完整的交易系统参数集)并计算其性能。如果这个人没有满足任何用户指定的要求,比如最低交易次数,他就会被拒绝,我们会再试一次。
当我们生成了popsize
个个体并进入过度初始化时,对于每个新的候选人,我们在现有的群体中搜索表现最差的个体。如果新的候选人优于群体中最差的个体,则新的候选人取代最差的个体。这稳定地提高了群体的质量,也使我们更有可能在全局最优的吸引域中有一个或多个个体。
然后我们来看代码的进化部分。有两个嵌套循环。外部循环处理世代,在每一代中,我们将当前群体中的每个个体作为主要父代。次要亲本和两个差异个体是随机选择的,当然这四个个体必须是不同的。
为了提高效率,变异(通过缩小差异来干扰次级亲本)和交叉(用相应的变异变量随机替换初级亲本中的一些变量)在同一循环中完成。我们遍历所有变量来创建一个试验个体。对于每一个,我们掷骰子,通常以很小的概率将变量设置为变异值。否则,我们从主父节点复制变量。
我们计算这个试验个体的表现。如果它优于原始亲本,它将进入下一代群体。否则,主要的父代会延续到下一代。
最后,我们可选地执行传统算法中没有出现的步骤,但是我发现该步骤在加速收敛方面是有用的,同时在存在多个次优局部最优解的情况下对算法找到全局最优解的能力影响很小或者没有影响。我们在人群中挑选一个个体,对当前最好的个体有所偏爱,我们也挑选一个变量。我们使用爬山算法来找到这个变量的值,以优化这个个体的性能。这给了我们两个世界(随机优化与爬山)的最佳选择,因为它让算法准确地收敛到精确的最优值,比纯粹的随机算法快得多,同时它不干扰差分进化找到全局最优值的能力。这是因为当它完成时,它只发生在一个个体上,这使该个体保持在其局部最优的吸引域内,而不触及群体中可能具有其自己的吸引域的其他个体。因此,吸引域保持分离,使全球最佳者最终占据主导地位。
在所有代完成后,我们选择最终种群中最好的个体,并将其返回给用户。
为了清楚起见,刚刚显示的算法被简化了。我的实现要复杂得多,因为这些年来我已经用许多方法对它进行了改进,以调整它的性能,尤其是在优化交易系统的情况下。从下一页开始,我们将完成整个子程序,分别列出和注释每个部分。这个代码可以在 DIFF_EV.CPP 文件中找到。注意,这个文件还包含一些与差异进化无关但同时执行起来很有效的其他代码。我们将在这里忽略这段代码,在第 91 页详细讨论。
DIFF_EV 差异进化的 CPP 程序
使用以下参数列表调用差分进化子例程:
int diff_ev (
double (*criter) ( double * , int ) , // Crit function maximized
int nvars , // Number of variables (trading system parameters)
int nints , // Number of first variables that are integers
int popsize , // Population size
int overinit , // Overinitialization for initial population
int mintrades , // Minimum number of trades for candidate system
int max_evals , // For safety, max number of failed initial performance evals
int max_bad_gen , // Max number of contiguous gens with no improvement
double mutate_dev , // Deviation for differential mutation
double pcross , // Probability of crossover
double pclimb , // Probability of taking a hill-climbing step, can be zero
double *low_bounds , // Lower bounds for parameters
double *high_bounds , // And upper
double *params , // Returns nvars best parameters, plus criterion at end
int print_progress // Print progress to screen?
)
调用者提供的criter()
函数计算交易系统的性能标准,该标准将被最大化。它采用交易系统可优化参数的向量。在我的实现中,还提供的整数是用户指定的最小交易数。读者应该会发现,在为生成的交易系统设置最低要求时,很容易添加其他变量。
参数可以是整数或实数;正如将要看到的,它们在内部被不同地处理。所有整数参数必须在参数数组中排在第一位,nints
指定整数的个数。
用户可以将overinit
设置为零,以使用传统版本的算法。然而,我发现将其设置为等于popsize
附*的值是有利的。这有助于加速收敛并增加找到真正全局最大值的概率。但请注意,收益递减点很快就会达到。很快就会发生这样的情况,稳定改进的群体中最差的个体通常优于大多数过度初始化的个体,使得继续过度初始化是一种浪费。
用户在mintrd
中指定所需交易的最小数量。从代码演示中可以看出,如果优化器很难找到满足这个要求的系统,那么指定的数量可能会自动减少。因此,用户应该检查通过最佳系统获得的交易数量,以确认它是令人满意的。如果程序员愿意,消除这种自动减少是很容易的,但我发现它很有用。
max_evals
参数是一种安全措施。如果交易系统本质上很差,以至于大多数试验参数都产生了被拒绝的系统,那么产生初始群体就要花费大量的时间。为了防止这种情况,请将max_evals
设置为一个较大但合理的值。这不应该被认为是一个收敛测试;实际上,这个极限应该永远不会遇到。
收敛由max_bad_gen
参数定义。如果这许多连续的代在最佳个体中没有改进,则获得收敛,并且算法停止。这通常应该是相当大的,也许 50 甚至更多,因为事情可能会因为运气不好而变得糟糕,然后突然再次起飞。
当一个突变的参数替换了主亲本中的相应参数时,就会发生一个交叉,这种情况发生的概率由pcross
给出。这通常应该很小,可能最多为 0.1 到 0.5。
爬山步骤的概率由pclimb
给出。这可以是零,以严格避免爬山,保持传统版本的差异进化。它可以被设置为一个很小的正值,比如 0.00001,在这种情况下,当前最好的个体(没有其他人)偶尔会爬山。这最大限度地提高了末期精确收敛。最后,可以将其设置为稍大但仍然较小的值,如 0.2。这样,除了调整最佳个体,它还会偶尔随机调整其他个体。将它设置为较大的值通常不是很有利,因为爬山是一种昂贵的操作,尤其是对于实参数,偶尔多做一次的回报通常不足以补偿增加的成本。此外,如果爬山太频繁,对真正的全局最大值的检测可能会有些受阻,尽管这通常不是问题。
调用者必须分别使用low_bounds
和high_bounds
向量来指定参数的下限和上限。
长度必须为nvars
+1 的params
向量返回最佳参数。这个数组中的最后一项是这个最优参数集的标准函数值。
如果print_progress
输入非零,频繁的进度报告将被打印到控制台屏幕上。
只分配了三个工作数组:一个用于保存“当前”群体,一个用于保存正在创建的群体,一个短数组用于跟踪最佳个体。我们使用failures
来计算初始群体中随机产生的个体被拒绝的次数,通常是因为交易系统的交易太少。我们很快就会看到,它将被用来降低最低交易要求。为了安全起见,n_evals
统计我们评估随机生成的个体以创建初始群体的总次数。这样可以紧急逃生,避免挂电脑。第一个popsize
个体填充pop1
数组,过度初始化进入pop2[0]
。
dim = nvars + 1 ; // Each individual is nvars variables plus criterion
pop1 = (double *) malloc ( dim * popsize * sizeof(double)) ;
pop2 = (double *) malloc ( dim * popsize * sizeof(double)) ;
best = (double *) malloc ( dim * sizeof(double)) ;
failures = 0 ; // Counts consecutive failures
n_evals = 0 ; // Counts evaluations for catastrophe escape
for (ind=0 ; ind<popsize+overinit ; ind++) {
if (ind < popsize) // If we are in pop1
popptr = pop1 + ind * dim ; // Point to the slot in pop1
else // Use first slot in pop2 for work
popptr = pop2 ; // Point to first slot in pop2
我们现在生成一个随机个体,并将其放入popptr
。第一个nints
参数为整数,其余为实数。这两种类型都是通过在各自的指定范围内统一选择值来生成的。然而,整数和实数的处理略有不同。函数unifrand()
生成一个 0-1 范围内的均匀随机数。
for (i=0 ; i<nvars ; i++) { // For all variables (parameters)
if (i < nints) { // Is this an integer?
popptr[i] = low_bounds[i]+(int)(unifrand() * (high_bounds[i]-low_bounds[i] + 1.0));
if (popptr[i] > high_bounds[i]) // Virtually impossible, but be safe
popptr[i] = high_bounds[i] ;
}
else // real
popptr[i] = low_bounds[i] + (unifrand () * (high_bounds[i] - low_bounds[i])) ;
} // For all parameters
评估此人的交易系统表现,参数设置在popptr
中。将此性能保存在popptr
的最后一个槽中,紧接在参数之后。回想一下,每个槽都是nvars+1
长。在构建初始群体时,计算性能评估的数量,以便我们可以将其作为紧急出口,以避免陷入表面上(或实际上!)死循环。最后,初始化第一个被测试者的最佳、最差和*均表现。这个memcpy()
将这个人的参数和表现复制到短数组中,在这里我们记录下有史以来最好的成绩,最终返回给用户。
value = criter ( popptr , mintrades ) ;
popptr[nvars] = value ; // Also save criterion after variables
++n_evals ; // Count evaluations for emergency escape
if (ind == 0) {
grand_best = worstf = avgf = value ;
memcpy ( best , pop1 , dim * sizeof(double) ) ; // Best so far is first!
}
下一个代码块处理被拒绝的个体。请注意,此代码使用零阈值来拒绝参数集,例如显示亏损或未能满足最小交易计数要求。如果您想要使用一个不同的性能标准,一个不适合这个阈值的标准,您应该修改这个代码,或者更好的是,转换您的性能标准。例如,如果您想要最大化利润因子,那么合适的阈值应该是 1 而不是 0,您可以将您的绩效定义为利润因子的对数。
在下面显示的拒绝处理代码中,我们首先检查我们是否有如此糟糕的交易系统,以至于生成初始群体所需的评估数量已经失控,在这种情况下,我们采取紧急退出。如果没有,我们计算这种失败的次数。如果达到了一个很大的数字(500 在这里是硬编码的;随意更改),我们重置失败计数器并降低最低交易要求,因为根据我的经验,这是最常见的失败原因,除非mintrades
被设置得非常小。在任何情况下,这个个体的失败导致它被跳过,而成功重置失败计数器。因此,需要一个批次的失败来触发最小交易计数的减少。在采取这种激烈的行动之前,情况必须非常糟糕。
if (value <= 0.0) { // If this individual is totally worthless
if (n_evals > max_evals) // Safety escape should ideally never happen
goto FINISHED ;
--ind ; // Skip it entirely
if (++failures >= 500) { // This many in a row
failures = 0 ;
mintrades = mintrades * 9 / 10 ;
if (mintrades < 1)
mintrades = 1 ;
}
continue ;
}
else
failures = 0 ;
我们保持最佳、最差和一般的表现。后两个是严格的进度报告,如果用户不会更新进度,最差和*均计算可以省略。
if (value > grand_best) { // Best ever
memcpy ( best , popptr , dim * sizeof(double) ) ;
grand_best = value ;
}
if (value < worstf)
worstf = value ;
avgf += value ;
如果我们已经找到了popsize
个个体,我们就进入了过度初始化。在现有人群中搜索最差的个体。如果这个新的过度初始化的个体优于最差的,用它替换最差的,这样就改善了基因库。回想一下,性能存储在参数之后,所以它在索引[nvars]
处。同样,我们只对用户更新保持*均性能;它在优化算法中不起作用。
if (ind >= popsize) { // If we finished pop1, now doing overinit
avgf = 0.0 ;
minptr = NULL ; // Not needed. Shuts up 'use before define'
for (i=0 ; i<popsize ; i++) { // Search pop1 for the worst
dtemp = (pop1+i*dim)[nvars] ;
avgf += dtemp ;
if ((i == 0) || (dtemp < worstf)) {
minptr = pop1 + i * dim ;
worstf = dtemp ;
}
} // Searching pop1 for worst
if (value > worstf) { // If this is better than the worst, replace worst with it
memcpy ( minptr , popptr , dim * sizeof(double) ) ;
avgf += value - worstf ; // Account for the substitution
}
} // If doing overinit
} // For all individuals (population and overinit)
此时,我们已经完全生成了初始群体。找到最好的执行者,因为我们偶尔会在上面做一点爬山(除非用户禁止这样做,这通常是一个糟糕的举动)。然后将点设置为旧(源)代和新(目的)代,并将收敛计数器归零。我们将使用n_tweaked
来控制爬山。
ibest = n_tweaked = 0 ;
value = pop1[nvars] ;
for (ind=1 ; ind<popsize ; ind++) {
popptr = pop1 + ind * dim ; // Point to the slot in pop1
if (popptr[nvars] > value) {
value = popptr[nvars] ;
ibest = ind ;
}
}
old_gen = pop1 ; // This is the old, parent generation
new_gen = pop2 ; // The children will be produced here
bad_generations = 0 ; // Counts contiguous generations with no improvement of best
我们有嵌套循环,代是外部循环,代中的个体是内部循环。仅针对可选用户更新跟踪*均和最差情况。变量improved
标记最佳个体是否在这一代中的任何一点上有所改进。这用于表示收敛。主父代parent1
来自源群体,我们将创建的子代将进入目标群体。
for (generation=1 ; ; generation++) {
worstf = 1.e60 ;
avgf = 0.0 ;
improved = 0 ; // Will flag if we improved in this generation
for (ind=0 ; ind<popsize ; ind++) { // Generate all children for this generation
parent1 = old_gen + ind * dim ; // Pure (and tested) parent
dest_ptr = new_gen + ind * dim ; // Winner goes here for next gen
我们随机选择第二个父代和两个差异。这些必须不同于主要的亲本并且彼此不同。
do { i = (int) (unifrand() * popsize) ; }
while ( i >= popsize || i == ind ) ;
do { j = (int) (unifrand() * popsize) ; }
while ( j >= popsize || j == ind || j == i ) ;
do { k = (int) (unifrand() * popsize) ; }
while ( k >= popsize || k == ind || k == i || k == j ) ;
parent2 = old_gen + i * dim ; // Parent to mutate
diff1 = old_gen + j * dim ; // First differential vector
diff2 = old_gen + k * dim ; // Second differential vector
下面的代码负责变异和交叉来创建一个新的子节点。我们将遍历每个参数,随机决定每个参数是否变异和交叉。如果我们到了最后一个参数,但还没有这样做,我们就这样做,以确保至少有一个变化。我们随机选择一个起始参数,这样当我们到达终点时,我们不会总是在同一个地方进行最后的动作。这种变异很容易将参数推到合法范围之外。根据需要修复此问题。ensure_legal()
例程将在后面讨论。
do { j = (int) (unifrand() * nvars) ; }
while ( j >= nvars ) ; // Pick a starting parameter
used_mutated_parameter = 0 ; // We must act at least once; we haven’t yet
for (i=nvars-1 ; i>=0 ; i--) {
if ((i == 0 && ! used_mutated_parameter) || (unifrand() < pcross)) {
dest_ptr[j] = parent2[j] + mutate_dev * (diff1[j] - diff2[j]) ;
used_mutated_parameter = 1 ;
} // We mutated this variable
else // We did not mutate this variable, so copy old value
dest_ptr[j] = parent1[j] ;
j = (j + 1) % nvars ; // Rotate through all variables
}
ensure_legal ( nvars , nints , low_bounds , high_bounds , dest_ptr ) ;
评估这个新创建的孩子的表现。如果优于初级亲本,放入目的群体。否则,将主要父代复制到目标群体中。记录有史以来最好的个人,它最终会返回给呼叫者。通过improved
表明我们这一代有所进步,所以我们还不准备放弃。变量n_tweaked
将很快与爬山结合使用。
value = criter ( dest_ptr , mintrades ) ;
if (value > parent1[nvars]) { // If the child is better than parent1
dest_ptr[nvars] = value ; // Get the child's value (The vars are already there)
if (value > grand_best) { // And update best so far
grand_best = value ;
memcpy ( best , dest_ptr , dim * sizeof(double) ) ;
ibest = ind ;
n_tweaked = 0 ;
improved = 1 ; // Flag that the best improved in this generation
}
}
else { // Else copy parent1 and its value
memcpy ( dest_ptr , parent1 , dim * sizeof(double) ) ;
value = parent1[nvars] ;
}
我们现在开始可选的(但非常有用的)爬山步骤。下面的代码是决定是否爬山和爬什么的逻辑。我们将在下一页讨论它。
if (pclimb > 0.0 &&
((ind == ibest && n_tweaked < nvars) || (unifrand() < pclimb))) {
if (ind == ibest) { // Once each generation tweak the best
++n_tweaked ; // But quit if done all vars
k = generation % nvars ; // Cycle through all vars
}
else { // Randomly choose an individual
k = (int) (unifrand() * nvars) ; // Which var to optimize
if (k >= nvars) // Safety only
k = nvars - 1 ;
}
如果用户指定pclimb
=0,那么爬山(这里称为调整)将永远不会完成。假设我们可以做到这一点,检查两个条件,其中任何一个都将允许对这个个体进行单次攀爬操作,这个个体可能是新创建的子体,也可能是主要父体的副本。如果个体是目前为止最好的,并且它的所有变量都没有被调整,我们就调整它。回想一下,每次 grand best 改变时,n_tweaked
被重置为零。如果这是最好的,我们计算这种调整,并根据代选择变量。最优秀的个体在连续多代中保持不变是很常见的,这种参数选择会导致调整在参数之间轮换,从而避免重复。
如果第一次测试失败(要么这不是最好的个体,要么它的所有参数都已经被调整),那么我们掷骰子,随机决定是否调整这个个体中随机选择的参数。
整数和实数参数的调整方式不同,前者更简单、更快。下面是整数代码的一半:
if (k < nints) { // Is this an integer?
ivar = ibase = (int) dest_ptr[k] ;
ilow = (int) low_bounds[k] ;
ihigh = (int) high_bounds [k] ;
success = 0 ;
while (++ivar <= ihigh) {
dest_ptr[k] = ivar ;
test_val = criter ( dest_ptr , mintrades ) ;
if (test_val > value) {
value = test_val ;
ibase = ivar ;
success = 1 ;
}
else {
dest_ptr[k] = ibase ;
break ;
}
}
我们在ibase
中保存了这个参数的当前值,因此如果没有发现改进,我们可以恢复它。我们将在当前值附*改变ivar
以寻求改进。对其整个合法范围进行全面的全球搜索通常是浪费时间。如果我们发现任何改进,变量success
就会标记。我们向上移动参数,直到它达到上限或者性能没有提高。(为了保持快速搜索,这里忽略了*坦性能之后进行改进的可能性。)只要我们在改进,我们就不断更新ibase
和改进后的值。当性能没有提高时,这可能发生在第一次测试中,我们将参数设置为ibase
,并停止前进。
如果增加参数没有成功,我们会尝试减少参数。该算法本质上与向上搜索算法相同,因此在这里讨论它没有意义。
if (! success) {
ivar = ibase ;
while (--ivar >= ilow) {
dest_ptr[k] = ivar ;
test_val = criter ( dest_ptr , mintrades ) ;
if (test_val > value) {
value = test_val ;
ibase = ivar ;
success = 1 ;
}
else {
dest_ptr[k] = ibase ;
break ;
}
} // While searching downward
} // If the upward search did not succeed
} // If k < nints (this parameter is an integer)
处理实参数的代码稍微复杂一点。如下一页所示,我们从将性能计算所需的信息复制到静态变量开始,所有这些都以local_
开始。该技术允许参数优化例程是通用的,调用仅引用被优化的参数的标准函数。
else { // This is a real parameter
local_criter = criter ;
local_ivar = k ; // Pass it to criterion routine
local_base = dest_ptr[k] ; // Preserve orig var
local_x = dest_ptr ;
local_nvars = nvars ;
local_nints = nints ;
local_low_bounds = low_bounds ;
local_high_bounds = high_bounds ;
old_value = value ;
优化分两步完成。首先,我们调用一个粗略的全局搜索例程glob_max()
(GLOB _ MAX 中的源代码。CPP),它测试一个范围内的一些离散点,并找到具有最大函数值的点。如果该值在一个端点处增加,它会一直前进,直到找到一个峰值。然后在brentmax()
(Brent max 中的源代码)中使用 Brent 的算法细化这个最大值。CPP)。不幸的是,这可能是一个昂贵的操作。但是回报往往是巨大的,特别是当差异进化已经让我们接*全局最大值,而我们所需要的只是最佳个体的精确最大化。
我们在参数的当前值附*开始粗略的全局搜索:
lower = local_base - 0.1 * (high_bounds[k] - low_bounds[k]) ;
upper = local_base + 0.1 * (high_bounds[k] - low_bounds[k]) ;
if (lower < low_bounds[k]) {
lower = low_bounds[k] ;
upper = low_bounds[k] + 0.2 * (high_bounds[k] - low_bounds[k]) ;
}
if (upper > high_bounds[k]) {
upper = high_bounds[k] ;
lower = high_bounds[k] - 0.2 * (high_bounds[k] - low_bounds[k]) ;
}
k = glob_max ( lower , upper , 7 , 0 , c_func ,
&x1 , &y1 , &x2 , &y2 , &x3 , &y3 ) ;
此时,我们有三个点,使得中心点具有最大函数值。对此进行细化,并调用ensure_legal()
来确保参数在其合法范围内。很可能是这种情况,或者至少非常接*,因为当超过合法界限时,标准函数应用巨大的惩罚,并且最大化例程将对该惩罚做出有力的响应。如果性能已经得到改善,即使在强制合法性之后(这几乎总是会发生的情况),保存高级参数并更新 grand best。最后,更新最差和*均性能,严格用于用户更新(不是算法的一部分)。
brentmax ( 5 , 1.e-8 , 0.0001 , c_func , &x1 , &x2 , &x3 , y2 ) ;
dest_ptr[local_ivar] = x2 ; // Optimized var value
ensure_legal ( nvars , nints , low_bounds , high_bounds , dest_ptr ) ;
value = criter ( dest_ptr , mintrades ) ;
if (value > old_value) {
dest_ptr[nvars] = value ;
}
else {
dest_ptr[local_ivar] = local_base ; // Restore original value
value = old_value ;
}
if (value > grand_best) { // Update best so far
grand_best = value ;
memcpy ( best , dest_ptr , dim * sizeof(double) ) ;
ibest = ind ;
n_tweaked = 0 ;
improved = 1 ; // Flag that the best improved in this generation
}
} // If optimizing real parameter
} // If doing hill-climbing step
if (value < worstf)
worstf = value ;
avgf += value ;
} // Create all children in this generation
我们差不多完成了。如果这一代发现最佳个体没有改进,则增加收敛计数器,如果达到用户指定的计数,则退出。但是如果我们真的得到了改善,重置计数器。然后针对源和目的地生成群体颠倒pop1
和pop2
的角色。剩下的少量代码只是清理工作,这里省略了。
if (! improved) {
++bad_generations ;
if (bad_generations > max_bad_gen)
goto FINISHED ;
}
else
bad_generations = 0 ;
if (old_gen == pop1) {
old_gen = pop2 ;
new_gen = pop1 ;
}
else {
old_gen = pop1 ;
new_gen = pop2 ;
}
} // For all generations
确保合法性的例程只是对照用户指定的限制检查每个参数,计算超出限制的严格惩罚(仅用于实际参数调整),并强制执行限制。对于整数,我们分别对待正值和负值,以确保正确的截断。回想一下,突变通常会导致整数参数获得非整数值,所以我们在这里首先解决这个问题。
static double ensure_legal ( int nvars , int nints , double *low_bounds , double
*high_bounds , double *params )
{
int i, j, varnum, ilow, ihigh ;
double rlow, rhigh, penalty, dtemp ;
penalty = 0.0 ;
for (i=0 ; i<nvars ; i++) {
if (i < nints) { // Is this an integer parameter?
if (params[i] >= 0)
params[i] = (int) (params[i] + 0.5) ;
else if (params[i] < 0)
params[i] = -(int) (0.5 - params[i]) ;
}
if (params[i] > high_bounds[i]) {
penalty += 1.e10 * (params[i] - high_bounds[i]) ;
params[i] = high_bounds[i] ;
}
if (params[i] < low_bounds[i]) {
penalty += 1.e10 * (low_bounds[i] - params[i]) ;
params[i] = low_bounds[i] ;
}
}
return penalty ;
}
由glob_max()
和brentmax()
调用的程序是被优化的单个参数的简单函数。将适当的参数设置为试验值,并调用ensure_legal()
来执行合法性并计算超出界限的可能惩罚。然后调用性能计算例程来计算交易系统的性能,并且从性能中减去惩罚(如果有的话)。
static double c_func ( double param )
{
double penalty ;
local_x[local_ivar] = param ;
penalty = ensure_legal ( local_nvars , local_nints , local_low_bounds ,
local_high_bounds , local_x ) ;
return local_criter ( local_x , mintrades ) - penalty ;
}
一个完整的程序来演示这个算法。CPP 将在第 112 页介绍。
四、后优化问题
便宜的偏差估计
在第 121 页,我们将详细介绍训练偏差,在第 286 页,我们将看到处理这个严重问题的有力方法。但是现在,我们将提供一个训练偏差的粗略概述,并展示如果一个人使用差分进化或一些类似的随机算法训练了一个交易系统,我们如何能够得到一个训练偏差的粗略但有用的估计,作为参数优化的廉价副产品。
当我们着手开发一个交易系统时,我们拥有一组历史数据,我们将根据这些数据优化我们的系统。这就是通常所说的 in-sample 或 IS 数据。当系统在不同的数据集上测试或投入使用时,该数据被称为样本外或 OOS 数据。我们实际上总是期望信息系统的性能将优于 OOS 的性能。这可能是由于几个因素,其中最重要的是,我们的 is 数据中存在的不可避免的噪声将在某种程度上被我们的训练算法误认为合法模式。当(根据定义)相同的噪声没有出现在 OOS 数据中时,性能将受到影响。
负责任的交易系统开发的一个关键方面是估计训练偏差,其表现超过 OOS 表现的程度。稍后,我们将看到一些相当精确的复杂方法。但是,当我们已经测试了大量随机参数组合作为随机优化过程的初步总体时,我们可以使用这些参数集和相关的逐棒回报来快速生成训练偏差的估计,虽然远没有使用更复杂的方法可获得的精度,但对于粗略的初步估计来说往往足够好。这给我们一个早期的想法,我们是否在正确的轨道上,它可能会节省我们更多的工作在一个方向,导致一个死胡同。
StocBias 类
文件 STOC _ 偏见。CPP 包含一个类的代码,让我们截取初步的人口生成,并使用这些数据来粗略估计训练偏差。要做到这一点,我们需要在初始群体生成期间访问每个试交易系统的逐棒回报。
随机或通过确定性网格搜索产生试验参数估计值是至关重要的。它们不能由任何智能引导搜索产生。因此,我们将检查用于构建差异进化初始种群的所有合法案例,但我们不能使用任何由突变和交叉产生的案例。
该算法的动机是这样的:假设我们预先选择一些棒作为单个 OOS 棒。当我们处理每个试验参数集时,我们将从所有试验中找到使所有其他棒线的总回报最大化的参数集——除了我们预先选择的 OOS 棒线之外的所有棒线。我们可以称之为设定。我们选择的 OOS 棒线在选择最佳性能参数集时不起作用,因为它在 is 回报的计算过程中被忽略。在我们检查了用于创建初始群体的所有参数集之后,我们的最佳参数集的每根棒线的返回减去我们的单个 OOS 棒线的返回,将是对训练偏差的粗略但真实的估计。
如果我们只对单个选择的 OOS 棒线进行这样的操作,我们的训练偏差估计将太容易受到随机变化的影响而没有用。但是很容易对每根棒线同时进行这种操作,然后合并单个的回报。对于任何参数集,我们只计算所有棒线的总回报。如果我们从总数中减去任何单个棒线的回报,差值就是该参数集的 is 回报,我们移除的棒线就是相应的 OOS 回报。当我们处理试验参数集时,我们分别跟踪每个棒线的最大值和对应于该上级的 OOS 回报,这样我们可以在以后减去。
主要的限制是,为了给出训练偏差的真正好的估计,我们需要为每个 is 集找到真正最优的参数集,并且历史数据中有多少条就有多少个 is 集。这显然不切实际。因为我们的“最优”仅仅是基于随机选择的试验参数集,所以我们不能期望很高的精确度。事实上,除非试验人群很大,也许至少有几千人,否则我们的偏倚评估将毫无价值。但是,通过在差异进化中使用大量的过度初始化,我们可以实现这一点,并提供一个很大的启动种群!
该类声明如下:
class StocBias {
public:
StocBias::StocBias ( int ncases ) ;
StocBias::~StocBias () ;
int ok ; // Flags if memory allocation went well
void collect ( int collect_data ) ;
void process () ;
void compute ( double *IS_mean , double *OOS_mean , double *bias ) ;
double *expose_returns () ;
private:
int nreturns ; // Number of returns
int collecting ; // Are we currently collecting data?
int got_first_case ; // Have we processed the first case (set of returns)?
double *returns ; // Returns for currently processed case
double *IS_best ; // In-sample best total return
double *OOS ; // Corresponding out-of-sample return
} ;
我们的StocBias
类的构造函数分配内存并初始化一些标志。collecting
标志表示我们是否正在收集和处理案例。当我们构建初始群体时,这必须打开(非零),在优化期间关闭。我省略了验证成功内存分配和设置ok
标志的代码。
StocBias::StocBias (
int nc
)
{
nreturns = nc ;
collecting = 0 ;
got_first_case = 0 ;
IS_best = (double *) malloc ( nreturns * sizeof(double) ) ;
OOS = (double *) malloc ( nreturns * sizeof(double) ) ;
returns = (double *) malloc ( nreturns * sizeof(double) ) ;
}
当我们想要开始收集试验参数集并返回时,调用下面的普通例程(用collect_data
=1 ),当我们完成收集时,再次调用它(用collect_data
=0 ):
void StocBias::collect ( int collect_data )
{
collecting = collect_data ;
}
我们可以让returns
成为公共的,但是 C++ 纯粹主义者希望它保持私有,并将其暴露给 criterion 例程,所以这就是我在这里所做的:
double *StocBias::expose_returns ()
{
return returns ;
}
每次调用参数评估例程时,该例程负责将逐条返回放置在此returns
中,然后调用process()
。
void StocBias::process ()
{
int i ;
double total , this_x ;
if (! collecting)
return ;
total = 0.0 ;
for (i=0 ; i<nreturns ; i++)
total += returns[i] ;
// Initialize if this is the first call
if (! got_first_case) {
got_first_case = 1 ;
for (i=0 ; i<nreturns ; i++) {
this_x = returns[i] ;
IS_best[i] = total - this_x ;
OOS[i] = this_x ;
}
}
// Keep track of best if this is a subsequent call
else {
for (i=0 ; i<nreturns ; i++) {
this_x = returns[i] ;
if (total - this_x > IS_best[i]) {
IS_best[i] = total - this_x ;
OOS[i] = this_x ;
}
}
}
}
process()
程序从对所有棒线的回报求和开始,以获得该试验参数集的总回报。如果这是第一次调用(got_first_case
为假),我们通过将IS_best[]
中的“目前为止最好的”is 返回设置为 is 返回来进行初始化,并且我们还初始化相应的 OOS 返回。回想一下,任何 OOS 棒线的 IS 回报率是除 OOS 棒线以外的所有回报率的总和。这很容易通过从所有回报的总和中减去 OOS 棒线的回报得到。
如果这是一个后续调用,过程是类似的,除了不是初始化IS_best[]
,如果这个返回大于运行最佳,我们更新它。如果我们做这个更新,我们也必须更新相应的 OOS 回报。
剩下的只是最终结果的琐碎计算。我们返回的值基于整个市场历史的总回报。IS_best[]
的每个元素都是nreturns
–1 棒线回报的总和,所以我们用这个量除总和,使总和与 OOS 回报的总和相称。
void StocBias::compute (
double *IS_return ,
double *OOS_return ,
double *bias
)
{
int i ;
*IS_return = *OOS_return = 0.0 ;
for (i=0 ; i<nreturns ; i++) {
*IS_return += IS_best[i] ;
*OOS_return += OOS[i] ;
}
*IS_return /= (nreturns - 1) ; // Each IS_best is the sum of nreturns-1 returns
*bias = *IS_return - *OOS_return ;
}
计算出偏差后,我们该怎么处理它呢?孤立地看,它的价值有限。此外,我们必须记住,这是一个粗略的估计。不过,从交易系统的总回报中减去偏差还是有用的,交易系统的总回报是从差分进化或其他优化算法产生的最优参数集中获得的。如果去掉*似的训练偏差,对参数集在样本之外的表现产生了不太好的估计,我们应该停下来,重新考虑我们的交易系统。
将此处计算的IS_return
与优化程序产生的最佳值进行比较非常重要。自然,它实际上总是更少;否则优化算法会很差!但理想情况下,它会相当接*。如果我们发现我们的IS_return
比最优回报小得多,我们应该得出结论,我们使用的试验参数集太少,因此我们的偏差估计会非常差。
在实际交易系统中,这个例程的完整例子将出现在第 112 页对 DEV_MA 程序的讨论中。
廉价的参数关系
在前面的部分中,我们看到了如何从随机优化例程(如差分进化)中借用初始群体来提供对训练偏差的快速而粗略的估计。在这一节中,我们将看到在优化完成后,如何使用最终群体来快速生成一些有趣的参数相互关系的度量。就像廉价的训练偏差一样,这些都是粗略的估计,有时会非常不准确。然而,通常情况下,它们会被证明是有趣和有用的,特别是如果使用了大量的样本,并且优化一直持续到获得稳定性。此外,作为本次演示的一部分,我们将指出如何修改算法以产生更可靠的估计,尽管代价是更多的计算时间。
这一发展中的一些数学超出了本书的范围,将作为陈述的事实提出,读者必须相信这一过程。此外,这种表述简化了许多权利要求,尽管从未达到不正确的程度。另一方面,这里没有什么真正深奥的东西;所有这些结果都是标准材料,广泛存在于标准统计参考资料中。考虑到这些警告...
多元函数的 Hessian 是函数对变量的二阶偏导数的矩阵。换句话说,海森矩阵的 i,j 元素是函数对第 i 个和第 j 个变量的偏导数。假设函数是概率密度的负对数似然;变量是概率密度函数的参数,我们计算了参数的最大似然估计。然后,对于一大类概率密度函数,包括古老的正态分布,参数估计的估计标准误差是 Hessian 矩阵的逆的相应对角线的*方根。事实上,Hessian 的逆是参数估计的协方差矩阵。
在任何统计学家开始尖叫之前,让我强调一下,交易系统的性能最大值与统计分布的对数可能性是非常不同的动物,所以类似地对待它们有点牵强。另一方面,一个优化的交易系统在其最大值附*的一般行为(或任何多元函数,就此而言)遵循相同的原则。其 Hessian 在最佳值附*的倒数描述了参数水*曲线的方向变化率。只要我们不谈论估计的标准误差,而是保持一切的相对性,我们就可以用一些相对简单的技术收集许多关于参数之间关系的信息。
该算法的完整源代码在 PARAMCOR 文件中。CPP 和 DEV_MA。CPP 程序将在第 112 页展示,它将在一个实际的交易系统中进行说明。我们现在一次处理代码的一部分。该例程的调用如下:
int paramcor (
int ncases , // Number of cases
int nparams , // Number of parameters
double *data // Ncases (rows) by nparams+1 input of trial pts and f vals
)
data
矩阵的结构与 DIFF_EV 中的结构相同。CPP 计划。每个个体(完整的参数集和性能指标)占据一行,参数排在前面,性能排在最后。这意味着优化完成后,可以用最终群体调用paramcor()
。就像我们在估计训练偏倚时所做的那样,用初始人群来称呼它是没有意义的。这是因为我们希望整个群体接*全局最优,事实上我们希望这个最优成为群体的一部分。
计算 Hessian 矩阵的一种快速简单的方法,也就是我们在这里要做的,是在最优值附*拟合一个最小二乘二次函数,然后直接计算 Hessian 矩阵。我们需要这个拟合中的参数数量:
if (nparams < 2)
return 1 ;
ncoefs = nparams // First-order terms
+ nparams * (nparams + 1) / 2 // Second-order terms
+ 1 ; // Constant
在继续之前,重要的是要强调,至少有两种计算 Hessian 的替代方法,这两种方法都需要更多的工作,但在精度方面可能更胜一筹。在本节描述的技术中发现价值的读者将很好地探索这些替代方法,每种方法都有自己的优点和缺点。以下是对它们的简单比较:
-
这里使用的方法是差异进化的廉价副产品。我们不需要重复评估各种参数集的性能,因为我们已经有了一个群体,其大多数成员相对接*最优。最小*方拟合的使用倾向于消除噪声。这种方法的最大缺点是,远非最佳的试验参数集可能会让计算变得麻烦。当我们有一个非常大的群体时,这种方法最有效,我们优化直到收敛是可靠的。
-
我们可以在最佳参数集附*抽取大量随机样本,评估每个样本的性能,然后像第一种方法一样进行最小二乘拟合。这具有显著的优点,即不会出现野参数集,也不会干扰计算。但是它确实需要大量的性能评估,如果进行大量的评估,会使代码变得复杂并增加大量的计算时间。更重要的是,选择一个适当程度的随机变异不是一件小事,而差异进化往往倾向于适当的值。
-
我们可以使用标准的数值方法,扰动每个参数并直接数值计算偏导数。同样,找到一个合适的扰动可能是困难的,误判会对准确性产生深远的影响。但如果小心行事,这几乎肯定是一个好方法。
在这里,我们处理一个恼人的启发式决策。为了将群体限制在接*最优的那些参数集,我们只保留最终群体的一部分,即那些与最优具有最小欧几里德距离的群体。我们有多少病例?我自己的启发是保留比要估计的系数多 50%的案例。如果这个值太小,我们可能得不到足够的变化来精确计算每一个干扰系数。如果它太大,我们可能会受到野生参数集的污染,远离最佳,我们无法获得准确的局部行为。但是根据我自己的经验,这个因素是相当可靠的,特别是如果人口很多的话(至少几百)。如果群体有数百人,增加该因子可能有利于增加能够模拟所有参数相互作用的可能性。
nc_kept = (int) (1.5 * ncoefs) ; // Keep this many individuals
if (nc_kept > ncases) {
return 1 ;
}
我们需要分配很多工作区域。我们将使用SingularValueDecomp
对象进行最小*方二次拟合。它的源代码可以在 SVDCMP.CPP 中找到,不熟悉这项技术的读者会发现很容易找到有关奇异值分解的更多信息,奇异值分解是一种标准而可靠的最小二乘拟合方法。我们还打开一个日志文件,来自该算法的信息将为用户写入其中。
sptr = new SingularValueDecomp ( nc_kept , ncoefs , 0 ) ;
coefs = (double *) malloc ( ncoefs * sizeof(double) ) ;
hessian = (double *) malloc ( nparams * nparams * sizeof(double) ) ;
evals = (double *) malloc ( nparams * sizeof(double) ) ;
evect = (double *) malloc ( nparams * nparams * sizeof(double) ) ;
work1 = (double *) malloc ( nparams * sizeof(double) ) ;
dwork = (double *) malloc ( ncases * sizeof(double) ) ;
iwork = (int *) malloc ( ncases * sizeof(int) ) ;
fopen_s ( &fp , "PARAMCOR.LOG" , "wt" ) ;
我们找到群体中最好的个体,并得到一个指向它的指针。
for (i=0 ; i<ncases ; i++) {
pptr = data + i * (nparams+1) ;
if (i==0 || pptr[nparams] > best_val) {
ibest = i ;
best_val = pptr[nparams] ;
}
}
bestptr = data + ibest * (nparams+1) ; // This is the best individual
我们将希望使用仅由那些最接*最佳参数集的个体组成的群体子集。这使我们能够专注于本地信息,而不会被远离最佳状态的性能变化所迷惑。要做到这一点,需要计算最优群体和群体中每个成员之间的欧几里德距离。对这些距离进行排序,同时移动它们的索引,这样我们最终得到排序后的个体的索引。使用欧几里德距离的一个含义是,我们必须以这样一种方式定义交易系统的参数,它们至少是大致相称的。否则,某些参数在计算距离时可能会权重过大或不足。稍后,我们将看到这一点之所以重要的另一个原因。子程序qsortdsi()
的源代码在 QSORTD.CPP 中。
for (i=0 ; i<ncases ; i++) {
pptr = data + i * (nparams+1) ;
sum = 0.0 ;
for (j=0 ; j<nparams ; j++) {
diff = pptr[j] - bestptr[j] ;
sum += diff * diff ;
}
dwork[i] = sum ;
iwork[i] = i ;
}
qsortdsi ( 0 , ncases-1 , dwork , iwork ) ; // Closest to most distant
这里我们使用奇异值分解来计算性能曲线的最小二乘拟合二次曲面的系数。这是一个二次方程,它提供了性能的最小*方误差估计,作为系数的函数,至少在最佳值附*。为了帮助数值稳定性,我们从每个其他个体中减去最佳个体的系数和参数值,从而将计算集中在最佳参数集周围。这在数学上是不必要的;如果不这样做,任何差异都会被吸收到常数偏移中。然而,它确实提供了一种快速简便的方法来提高数值稳定性。源代码文件 SVDCMP 开头的注释。CPP 对这里发生的事情提供了一些解释,更多的细节可以很容易地在网上或许多标准回归教科书中找到。
aptr = sptr->a ; // Design matrix goes here
best = data + ibest * (nparams+1) ; // Best individual, parameters and value
for (i=0 ; i<nc_kept ; i++) { // Keep only the nearby subset of population
pptr = data + iwork[i] * (nparams+1) ;
for (j=0 ; j<nparams ; j++) {
d = pptr[j] - best[j] ; // This optional centering slightly aids stability
*aptr++ = d ; // First-order terms
for (k=j ; k<nparams ; k++) {
d2 = pptr[k] - best[k] ;
*aptr++ = d * d2 ; // Second-order terms
}
}
*aptr++ = 1.0 ; // Constant term
sptr->b[i] = best[nparams] - pptr[nparams] ; // RHS is function values, also centered
}
sptr->svdcmp () ;
sptr->backsub ( 1.e-10 , coefs ) ; // Computes optimal weights
此时我们有了coefs
中的二次函数系数。常数 1 . e–10 是启发式的,并不十分重要。它只是控制在接*奇点的情况下计算系数的程度,这在本应用中几乎不可能获得。如果用户感兴趣,我们在这里省略了打印系数的冗长代码。
在刚刚显示的代码中应该注意到一些微妙但至关重要的事情:我们翻转了性能的符号。这将问题从最大化转换为最小化,类似于最小化统计分布的负对数可能性。这是不必要的;没有符号颠倒,我们需要的结果也会随之而来。这不仅很好地符合了传统的用法,而且这也给了我们对角线上的正数,当打印出来的时候,更容易阅读,对用户更友好。
从二次拟合计算 Hessian 矩阵很简单,只需对每个参数的每一项微分一次。当然,这意味着我们计算对角线项的二阶导数,其中相同的参数出现两次。线性项微分两次就消失了。矩阵是对称的,所以我们只需将一项复制到另一项。
cptr = coefs ;
for (j=0 ; j<nparams ; j++) {
++cptr ; // Skip the linear term
for (k=j ; k<nparams ; k++) {
hessian[j*nparams+k] = *cptr ;
if (k == j) // If this is a diagonal element
hessian[j*nparams+k] *= 2.0 ; // Second partial is twice coef
else // If off-diagonal
hessian[k*nparams+j] = *cptr ; // Copy the symmetric element
++cptr ;
}
}
这是一个简短离题的好地方,讨论什么可能出错,以及为什么表面上的问题实际上可能没有看起来那么严重。有些问题本身就能提供信息。回想一下,因为我们翻转了性能度量的符号,所以我们现在最小化了我们的函数。这意味着,如果我们处于真正的局部(理想情况下是全局!)最小值,函数相对于每个参数(Hessian 矩阵的对角线)的二阶导数将严格为正。但是如果一条或多条对角线是零,或者,但愿不会,是负数,那该怎么办呢?
简而言之,后续的计算会受到严重影响。请记住,我们的基本假设是我们处于最小值(我们的性能处于最大值)。我们将冒险得出的关于参数关系的一切都取决于这一假设的有效性。以下是关于这个问题的一些想法:
-
在随后的计算中,必须忽略对角线元素不是正的任何参数。至少就数据的最小二乘拟合而言,该参数不是最佳值。
-
“局部”是一个主观描述。一个参数可能确实在其位置的狭窄邻域内处于局部最优,但是这个局部最优可能不是全局的。
-
该参数可能确实是全局最优的,但是最小二乘拟合被扩展到这样的距离,以致于它不再代表函数的局部行为。换句话说,最小二乘拟合是问题所在,因为它被要求*似高度非二次行为。
-
也许最重要的是,非正对角线是一个红色信号,表明交易系统的参数化是不稳定的。通常,这表明性能曲线不是每个参数的*滑函数,而是上下剧烈跳动。参数的微小变化可能会剧烈移动性能,或者可能会向上移动,再移动一点点后向下移动,然后再次向上移动,多次。当交易系统,而不是可靠地利用可重复的模式,或多或少地随机捕捉大赢,然后大输时,就会发生这种情况,因为参数在其范围内来回变化。这是不良行为。
-
前面陈述的一个推论是“本地”行为应该尽可能地扩展到本地之外。如果性能曲线以一种接*最佳的方式运行,但很快就改变为不同的方式,这是一个危险的系统。对于每个参数,我们都希望在最佳值附*看到一个宽的性能峰值,随着我们远离最佳值,性能会*稳下降。
前面几点的结论是,如果我们发现一条或多条对角线是非正的,我们不应该诅咒算法,而是自动考虑切换到数值微分作为最小二乘拟合方法的替代方法,这种方法具有很好的噪声消除特性。相反,我们应该仔细观察我们的交易系统,特别是绘制敏感度曲线,这将在第 108 页讨论。
好了,说得够多了,所以我们继续讨论如何处理负对角线。这很简单:只需将任何对角线元素及其行和列设置为零。这将从所有后续计算中删除它。
for (j=0 ; j<nparams ; j++) {
if (hessian[j*nparams+j] < 1.e-10) {
for (k=j ; k<nparams ; k++)
hessian[j*nparams+k] = hessian[k*nparams+j] = 0.0 ;
}
}
同样,当且仅当 Hessian 矩阵是半正定时,我们处于局部最小值(记住我们翻转了性能的符号)。但是野参数值有可能导致 Hessian 不具有该属性的二次拟合。如果有必要,我们通过限制非对角元素来鼓励这种做法,尽管奇怪的相关模式仍然可能产生负的特征值。
for (j=0 ; j<nparams-1 ; j++) {
d = hessian[j*nparams+j] ; // One diagonal
for (k=j+1 ; k<nparams ; k++) {
d2 = hessian[k*nparams+k] ; // Another diagonal
limit = 0.99999 * sqrt ( d * d2 ) ;
if (hessian[j*nparams+k] > limit) {
hessian[j*nparams+k] = limit ;
hessian[k*nparams+j] = limit ;
}
if (hessian[j*nparams+k] < -limit) {
hessian[j*nparams+k] = -limit ;
hessian[k*nparams+j] = -limit ;
}
}
}
如果任何对角线被置零,Hessian 矩阵用通常的方法是不可逆的,我们很快就会需要它的特征值和向量,所以我们计算它们并用它们来计算 Hessian 矩阵的广义逆。我们将逆矩阵放回到 Hessian 矩阵中,以避免又一次内存分配。evec_rs()
的源代码在 EVER_RS.CPP 中。
evec_rs ( hessian , nparams , 1 , evect , evals , work1 ) ;
for (j=0 ; j<nparams ; j++) {
for (k=j ; k<nparams ; k++) {
sum = 0.0 ;
for (i=0 ; i<nparams ; i++) {
if (evals[i] > 1.e-8)
sum += evect[j*nparams+i] * evect[k*nparams+i] / evals[i] ;
}
hessian[j*nparams+k] = hessian[k*nparams+j] = sum ; // Generalized inverse
}
}
我们终于准备好打印一些真正有用的信息。我们从每个参数的相对变化开始。如果我们正在处理负对数似然分布,这些值将是参数的最大似然估计的估计标准误差。但是因为我们离那个场景很远,所以我们重新调整,使最大变化参数的值为 1.0。这些是每个参数可以变化的相对量,同时对交易系统的性能具有最小的影响。较大的值意味着系统相对不受参数变化的影响。计算缩放比例,然后将它们打印在生产线上。有关此输出的示例,请快速查看第 118 页。
for (i=0 ; i<nparams ; i++) { // Scale so largest variation is 1.0
if (hessian[i*nparams+i] > 0.0)
d = sqrt ( hessian[i*nparams+i] ) ;
else
d = 0.0 ;
if (i == 0 || d > rscale)
rscale = d ;
}
strcpy_s ( msg , " " ) ;
for (i=0 ; i<nparams ; i++) {
sprintf_s ( msg2, " Param %d", i+1 ) ;
strcat_s ( msg , msg2 ) ;
}
fprintf ( fp , "\n%s", msg ) ;
strcpy_s ( msg , " Variation-->" ) ;
for (i=0 ; i<nparams ; i++) {
if (hessian[i*nparams+i] > 0.0)
d = sqrt ( hessian[i*nparams+i] ) / rscale ;
else
d = 0.0 ;
sprintf_s ( msg2 , " %12.3lf", d ) ;
strcat_s ( msg , msg2 ) ;
}
fprintf ( fp , "\n%s", msg ) ;
现在,我们可以通过用标准差换算协方差来计算和打印参数相关性。
for (i=0 ; i<nparams ; i++) {
sprintf_s ( msg, " %12d", i+1 ) ;
if (hessian[i*nparams+i] > 0.0)
d = sqrt ( hessian[i*nparams+i] ) ; // ‘Standard deviation’ of one parameter
else
d = 0.0 ;
for (k=0 ; k<nparams ; k++) {
if (hessian[k*nparams+k] > 0.0)
d2 = sqrt ( hessian[k*nparams+k] ) ; // ‘Standard deviation’ of the other
else
d2 = 0.0 ;
if (d * d2 > 0.0) {
corr = hessian[i*nparams+k] / (d * d2) ;
if (corr > 1.0) // Keep them sensible
corr = 1.0 ;
if (corr < -1.0)
corr = -1.0 ;
sprintf_s ( msg2 , " %12.3lf", corr ) ;
}
else
strcpy_s ( msg2 , " -----" ) ; // If either diagonal is zero, corr is undefined
strcat_s ( msg , msg2 ) ;
}
fprintf ( fp , "\n%s", msg ) ;
}
同样,如果你想看打印出来的样本,请看第 118 页。
我们现在来看看我认为最有趣、最有启发性的成果。Hessian 矩阵的特征向量定义了作为参数函数的交易系统性能的水*曲线椭圆的主轴。特别地,最大特征值对应的特征向量是参数变化引起性能变化最大的方向,即灵敏度最大的方向。最小特征值对应的特征向量是导致性能变化最小的方向,即灵敏度最小的方向。
我们找到这两个极端的特征值。除非我们至少有两个正的特征值,否则继续下去是没有意义的。当然,如果只有一个,一些用户可能想继续,但是如果情况很糟糕,只有一个正特征值,交易系统是如此不稳定,整个过程可能是毫无意义的。
for (k=nparams-1 ; k>0 ; k--) { // Find the smallest positive eigenvalue
if (evals[k] > 0.0)
break ;
}
if (! k)
goto FINISH ;
为了更容易理解,我选择缩放方向,使得每个方向向量中的最大元素为 1.0。计算比例因子,然后打印输出。
fprintf ( fp, "\n Max Min\n" ) ;
lscale = rscale = 0.0 ; // Scale so largest element is 1.0\. Purely heuristic.
for (i=0 ; i<nparams ; i++) {
if (fabs ( evect[i*nparams] ) > lscale)
lscale = fabs ( evect[i*nparams] ) ;
if (fabs ( evect[i*nparams+k] ) > rscale)
rscale = fabs ( evect[i*nparams+k] ) ;
}
for (i=0 ; i<nparams ; i++) {
sprintf_s ( msg, " Param %d %10.3lf %10.3lf",
i+1, evect[i*nparams] / lscale, evect[i*nparams+k] / rscale) ;
fprintf ( fp , "\n%s", msg ) ;
}
第 119 页给出了一个真实交易系统的输出示例。
参数灵敏度曲线
前面的章节介绍了快速简单的方法来估计训练偏差和发现参数之间的关系。这两种方法都很粗糙,容易出现重大错误,而且它们的信息对于负责任的交易系统的开发并不重要。尽管如此,我还是喜欢在我的开发系统中包含这些功能,因为它们几乎没有增加计算开销,而且它们的结果几乎总是很有趣。但请理解,这一部分的主题是至关重要的,必须被视为任何交易系统开发人员的最低限度的尽职调查。
数字是展示信息的绝佳方式,但没有什么能胜过一张图片。特别是,我们应该检查交易系统性能的图表,因为参数围绕它们的计算最优值变化。我们希望看到*滑的曲线,尤其是在最佳值附*。在更远的值上的反弹不是很重要,但是在最佳值附*,我们想要*滑的行为。如果最优值处于窄峰,我们的交易系统就会不稳定;当市场条件不可避免地演变成久而久之时,曾经的最佳价值将跌落悬崖,不再接*最佳。此外,如果我们在最佳值附*有明显的多个峰值,这是一个迹象,表明系统可能由于幸运地锁定了一些好的交易和/或避免了一些坏的交易而获得了很高的性能。一个参数的微小变化交替地在这些特殊交易中获利或亏损,这意味着运气在系统回溯测试中扮演了过度的角色。
另一方面,如果我们看到交易系统的性能随着参数偏离其训练值而缓慢而*稳地从最佳值下降,我们知道系统对扰动的反应很温和,可能对运气的变化有很好的免疫力,并且可能在未来的一段时间内保持稳定。
计算这些灵敏度曲线几乎非常简单,但是我们还是要检查代码。如果可能的话,在实践中最好在计算机屏幕上显示这些*滑的曲线。但是为了简单起见,这里我使用了笨拙但实用的方法,将直方图打印到文本文件中。这不是最优雅的方法,但是很简单,而且很有效。
我们将要看到的例程的代码在 SENSITIV.CPP 中。子例程的调用如下:
int sensitivity (
double (*criter) ( double * , int ) , // Crit function maximized
int nvars , // Number of variables
int nints , // Number of first variables that are integers
int npoints , // Number of points at which to evaluate performance
int nres , // Number of resolved points across plot
int mintrades , // Minimum number of trades
double *best , // Optimal parameters
double *low_bounds , // Lower bounds for parameters
double *high_bounds // And upper
)
标准函数与我们在差分进化中看到的相同,采用试验参数的向量和所需的最小交易数。我们有nvars
个参数,其中第一个nint
是整数。每个参数将在其low_bound
到high_bound
范围内以等间距的npoints
值进行评估。水*直方图将具有从零到最大性能值的nres
离散值。负性能被绘制成好像它们是零。我们还需要最优参数值的best
向量。
我们分配内存并打开结果将被写入的文本文件。然后我们开始处理每个参数的主循环。该循环的第一步是将所有参数设置为它们的最佳值,以便每次只有一个参数偏离其最佳值。
vals = (double *) malloc ( npoints * sizeof(double) ) ;
params = (double *) malloc ( nvars * sizeof(double) ) ;
fopen_s ( &fp , "SENS.LOG" , "wt" ) ;
for (ivar=0 ; ivar<nvars ; ivar++) {
for (i=0 ; i<nvars ; i++)
params[i] = best[i] ;
整数和实数参数是分开处理的,整数稍微复杂一些。下面是这段代码。整数值应该在浮点参数向量中精确表示,但是我们采取了异常不会导致问题的廉价保险。
if (ivar < nints) {
fprintf ( fp , "\n\nSensitivity curve for integer parameter %d (optimum=%d)\n",
ivar+1, (int) (best[ivar] + 1.e-10) ) ;
label_frac = (high_bounds[ivar] - low_bounds[ivar] + 0.99999999) / (npoints - 1) ;
for (ipoint=0 ; ipoint<npoints ; ipoint++) {
ival = (int) (low_bounds[ivar] + ipoint * label_frac) ;
params[ivar] = ival ;
vals[ipoint] = criter ( params , mintrades ) ;
if (ipoint == 0 || vals[ipoint] > maxval)
maxval = vals[ipoint] ;
}
hist_frac = (nres + 0.9999999) / maxval ;
for (ipoint=0 ; ipoint<npoints ; ipoint++) {
ival = (int) (low_bounds[ivar] + ipoint * label_frac) ;
fprintf ( fp , "\n%6d|", ival ) ;
k = (int) (vals[ipoint] * hist_frac) ;
for (i=0 ; i<k ; i++)
fprintf ( fp , "*" ) ;
}
}
在前面的代码中,确保测试和打印尽可能等距的整数值有点棘手。我们将label_frac
计算为每一步到下一点的参数值增量。如果你不理解计算,在边界值处测试公式。找到测试点中的最大性能后,计算直方图比例为hist_frac
。然后,我们传递保存的性能值,计算要打印的字符数,并这样做。
实数参数稍微容易一些,因为我们不用担心测试严格的整数值。这是代码。不需要任何解释,因为它是刚刚显示的整数代码的简化版本。
else {
fprintf ( fp , "\n\nSensitivity curve for real parameter %d (optimum=%.4lf)\n", ivar+1,
best[ivar] ) ;
label_frac = (high_bounds[ivar] - low_bounds[ivar]) / (npoints - 1) ;
for (ipoint=0 ; ipoint<npoints ; ipoint++) {
rval = low_bounds[ivar] + ipoint * label_frac ;
params[ivar] = rval ;
vals[ipoint] = criter ( params , mintrades ) ;
if (ipoint == 0 || vals[ipoint] > maxval)
maxval = vals[ipoint] ;
}
hist_frac = (nres + 0.9999999) / maxval ;
for (ipoint=0 ; ipoint<npoints ; ipoint++) {
rval = low_bounds[ivar] + ipoint * label_frac ;
fprintf ( fp , "\n%10.3lf|", rval ) ;
k = (int) (vals[ipoint] * hist_frac) ;
for (i=0 ; i<k ; i++)
fprintf ( fp , "*" ) ;
}
}
}
在下一节中,我们将看到一个在实际应用环境中绘制参数灵敏度图的例子。
把这一切放在一起交易 OEX
我们现在提出一个程序,它结合了差分进化、廉价的训练偏差估计、廉价的参数关系计算和绘制参数灵敏度曲线。这个程序的源代码在 DEV_MA.CPP 中,交易算法是四参数阈值移动*均线交叉系统。读者用自己的交易系统替换这个系统应该没有问题。
交易系统
通常我不太关注本书例子中使用的交易系统,而是关注讨论中的技术。但是在这种情况下,交易系统与技术紧密相连,用户理解它是很重要的。在这里尤其如此,因为在 PARAMCOR 中计算的参数关系。当参数成比例缩放时,CPP 是最有意义的,所以如果您实现一个系统,请确保这样做。
该系统的原理是计算原木价格的短期和长期移动*均值。如果短期均线超过长期均线至少一个指定的多头阈值,第二天就做多头。如果短期移动*均线比长期移动*均线低至少一个指定的空头阈值,就建立空头头寸。否则,我们保持中立。因此,有四个参数:两个回顾和两个阈值。评估例程调用如下:
double test_system (
int ncases , // Number of prices in history
int max_lookback , // Max lookback that will ever be used
double *x , // Log prices
int long_term , // Long-term lookback
double short_pct , // Short-term lookback is this / 100 times long_term, 0-100
double short_thresh , // Short threshold times 10000
double long_thresh , // Long threshold times 10000
int *ntrades , // Returns number of trades
double *returns // If non-NULL returns ncases-max_lookback bar returns
)
只有一个可优化的参数是整数,即长期回看。短期回顾被指定为长期回顾的百分比。短阈值和长阈值被指定为实际阈值的 10,000 倍。这是因为在实践中,最佳阈值将非常小,并且使用该乘数将阈值提高到与其他两个参数相当的范围。如果我们用实际的阈值,参数。由于缩放比例的巨大差异,CPP 算法将变得几乎毫无价值。不过,其他一切都会好的。
如果需要,最后一个参数returns
可以输入空值。但是如果为非空,则在那里放置单个的回车。STOC _ 偏差. CPP 中的廉价偏差估计例程需要这些信息
第一步是将指定的相应比例的参数转换成在这里有意义的值。读者们,如果你们用自己的交易系统代替这个系统,一定要注意这个相称的比例要求!如果使用的话,还要初始化总回报累积器、交易计数器和returns
的索引。
short_term = (int) (0.01 * short_pct * long_term) ;
if (short_term < 1)
short_term = 1 ;
if (short_term >= long_term)
short_term = long_term - 1 ;
short_thresh /= 10000.0 ;
long_thresh /= 10000.0 ;
sum = 0.0 ; // Cumulate performance for this trial
*ntrades = 0 ;
k = 0 ; // Will index returns
穿越市场历史的主循环在这里。请注意,不管long_term
如何,我们总是在同一根棒线上开始交易,以求一致。这很重要。计算短期移动*均线。
for (i=max_lookback-1 ; i<ncases-1 ; i++) { // Sum performance across history
short_mean = 0.0 ; // Cumulates short-term lookback sum
for (j=i ; j>i-short_term ; j--)
short_mean += x[j] ;
然后我们计算长期移动*均线,注意我们要利用已经对短期移动*均线做的求和。
long_mean = short_mean ; // Cumulates long-term lookback sum
while (j>i-long_term)
long_mean += x[j--] ;
short_mean /= short_term ;
long_mean /= long_term ;
将短期/长期均线差与阈值进行比较,并相应地计算下一根棒线的回报率。请注意,我选择用比率而不是差来定义差异。我更喜欢这种标准化,尽管它是不对称的,但是请不要反对,特别是因为我们正在处理日志价格。实际上,这种差别是很小的。
change = short_mean / long_mean - 1.0 ; // Fractional diff in MA of log prices
if (change > long_thresh) { // Long position
ret = x[i+1] - x[i] ;
++(*ntrades) ;
}
else if (change < -short_thresh) { // Short position
ret = x[i] - x[i+1] ;
++(*ntrades) ;
}
else
ret = 0.0 ;
sum += ret ;
if (returns != NULL)
returns[k++] = ret ;
} // For i, summing performance for this trial
return sum ;
}
链接标准例程
将交易系统的参数化嵌入到差分进化例程或任何其他通用例程中是糟糕的编程风格。嵌入参数mintrades
已经够糟糕了,但是因为这是一个交易系统应用程序,而且这是一个常见的参数,我觉得这样做是有道理的。但是剩下的参数,可能会随着不同的交易系统而显著变化,必须作为一个真实的向量来提供。因此,我们需要一种方法将通用标准例程映射到最终的性能评估器,并传递多余的参数。我一直使用的标准方法是让讨厌的参数是静态的,并使用一个标准包装器。特别是,我在程序的顶部做静态声明,并在需要之前设置它们。此处还显示了包装:
static int local_n ;
static int local_max_lookback ;
static double *local_prices ;
double criter ( double *params , int mintrades )
{
int long_term, ntrades ;
double short_pct, short_thresh, long_thresh, ret_val ;
long_term = (int) (params[0] + 1.e-10) ; // This addition likely not needed
short_pct = params[1] ;
short_thresh = params[2] ;
long_thresh = params[3] ;
ret_val = test_system ( local_n , local_max_lookback , local_prices , long_term ,
short_pct , short_thresh , long_thresh , &ntrades ,
(stoc_bias != NULL) ? stoc_bias->expose_returns() : NULL ) ;
if (stoc_bias != NULL && ret_val > 0.0)
stoc_bias->process () ;
if (ntrades >= mintrades)
return ret_val ;
else
return -1.e20 ;
}
上一页的代码很好地演示了一种使用通用包装器的简洁方法,这种包装器将像diff_ev()
这样的工具箱例程与交易系统之间的差异以及像价格历史和交易开始棒线这样的讨厌参数隔离开来。我们只需要确保在调用criter()
例程之前local_
静态被设置为正确的值。这个包装器还负责检查是否满足最低交易要求,并处理StocBias
处理(第 92 页)。
我们将跳过*庸的市场阅读代码;见 DEV_MA。CPP 了解详情。在市场被读取之后,我们初始化传递讨厌的参数的静态数据,我们为四个可优化的参数设置界限,并且我们设置一个最小交易计数。创建StocBias
对象,使用差分进化进行优化,并计算偏差,我们可以从最佳性能中减去偏差,以获得估计的真实性能。最后,我们做敏感性测试。
local_n = nprices ; local_max_lookback = max_lookback ;
local_prices = prices ;
low_bounds[0] = 2 ;
low_bounds[1] = 0.01 ;
low_bounds[2] = 0.0 ;
low_bounds[3] = 0.0 ;
high_bounds[0] = max_lookback ;
high_bounds[1] = 99.0 ;
high_bounds[2] = max_thresh ; // These are 10000 times actual threshold
high_bounds[3] = max_thresh ;
mintrades = 20 ;
stoc_bias = new StocBias ( nprices - max_lookback ) ; // This many returns
diff_ev ( criter , 4 , 1 , 100 , 10000 , mintrades , 10000000 , 300 , 0.2 , 0.2 , 0.3 ,
low_bounds , high_bounds , params , 1 , stoc_bias ) ;
stoc_bias->compute ( &IS_mean , &OOS_mean , &bias ) ;
delete stoc_bias ;
stoc_bias = NULL ; // Needed so criter() does not process returns in sensitivity()
sensitivity ( criter , 4 , 1 , 30 , 80 , mintrades , params , low_bounds , high_bounds ) ;
适用于交易 OEX
从一开始到 2017 年年中,我使用 S&P 100 指数 OEX 运行了 DEV_MA 程序。图 4-1 显示了程序的主要输出。我们看到总对数回报是 2.671,最优参数(长回看,短回看占长回看的百分比,10,000 倍短阈值,10,000 倍长阈值)也显示出来。剩下的四行数字来自于StocBias
操作,预期收益 2.3489 是优化收益 2.6710 减去偏差 0.3221。
图 4-1
OEX DEV _ MA 的主要输出
图 4-2 显示了 PARAMCOR 产生的输出。CPP 算法(第 96 页)。检查变异排。在一个极端情况下,我们看到短期回顾和短期阈值在其最佳值附*对性能的影响最小,长期回顾的影响也稍小。突出的参数是长阈值,其具有极端的敏感性。即使其值发生微小的变化,也会对性能产生极大的影响。
图 4-2
OEX DEV _ MA 的 PARAMCOR 输出
短回望和短阈值之间的相关性为–0.679,表明一个阈值的变化可以被另一个阈值的相反变化所抵消。我无法解释这个意外的现象。
这些观察结果得到了最大灵敏度方向的支持,该方向几乎完全受长阈值支配。主导权重是–1 而不是 1 的事实是不相关的;这是一个方向,它可能指向任何一个方向。
最小影响的方向更有趣,它证实了前面提到的相关性。我们看到,移动参数——使短期回顾占长期回顾的百分比在一个方向上,而短期阈值几乎在相反方向上——是所有可能的参数变化中对性能产生最小影响的参数变化方向。太迷人了。
图 4-3 至图 4-6 显示了四个参数的灵敏度曲线。请注意,特别是对于两个阈值参数,之前报告的变化与图明显一致,其中参数 3 具有最小灵敏度,参数 4 具有最大灵敏度。
图 4-6
长阈值灵敏度
图 4-5
短阈值灵敏度
图 4-4
短回送百分比的灵敏度
图 4-3
长回送的灵敏度
五、评估未来表现 I:无偏交易模拟
这一章的标题是乐观的,也许是可耻的。众所周知,金融市场变化无常。它们是不稳定的(它们的统计特性会随着时间而变化),易受不可预见的外部冲击的影响,偶尔会受到没有明显原因的剧烈波动的影响,并且通常是不合作的。认为我们可以在很大程度上评估交易系统的未来表现的想法是可笑的。但是我们经常做的是识别那些预期未来回报非常低的交易系统,所以我们可以保持警惕。自然地,我们真正喜欢的是识别系统的能力,这些系统具有很高的未来回报的可能性。而我们也可能偶尔运气好,享受这种难得的奖励。试试也无妨。但是读者必须明白,这一章的真正目的是使用严格的统计方法来剔除那些表面上有希望的系统,这些系统实际上应该被丢弃,或者至少在投入实际货币使用之前进行修改。
样本内和样本外性能
开发者很少会在确切地说构想出一个交易系统,也就是它的最终形式。绝大多数时候,开发者会假设一个家族的交易系统。该家族的任何特定成员将由一个或多个参数的值来定义。举一个相当*凡的例子,开发商可能假设,如果最*市场价格的短期移动*均线越过长期移动*均线,这个市场的趋势已经改变,是时候做多了,如果相反的情况发生,就做空。但是短期和长期是什么意思呢?在我们有一个实际的交易系统之前,每个均线的回望周期必须被指定。
我们如何选择有效的回顾期?显而易见的方法是,在计算机时间和其他资源允许的情况下,尝试尽可能多的值,并选择任何一对长期和短期回顾给出最佳结果。我将附带说明,我们用来定义“最佳结果”的标准可能很重要,我们将在后面讨论。现在,假设我们有一种衡量交易系统性能的方法,允许我们选择最好的参数。
如果我们处理的是完美的、无噪声的数据,那么我们通过优化数据集的短期和长期回顾所获得的性能结果通常会反映出我们将来会看到的结果。不幸的是,金融市场的数据就像它得到的一样嘈杂。实际上,市场价格被噪音所主导,只有很少的真实模式隐藏在噪音之下。
这种嘈杂情况的含义是,我们的“最优”参数最终以这样一种方式被选择,即我们的交易系统符合训练集中的噪音模式,甚至比真实的市场模式更好。根据定义,噪音不会重复。结果,当我们把有前途的交易系统投入使用时,我们可能会发现它几乎或完全没有价值。这在任何应用中都是一个问题,但在市场交易中尤其具有破坏性,因为金融市场是由噪声主导的。
交易系统运行的这两种环境有标准的名称。我们用来优化系统参数(如移动*均交叉系统中的短期和长期回顾)的数据集称为样本内 ( 是)数据集。任何没有参与参数优化的数据集被称为样本外 ( OOS )。IS 表现超过 OOS 表现的程度称为训练偏差。本章主要致力于量化和处理这种影响。
值得一提的是,训练偏差可能是由至少两种完全不同的效应引起的。我们已经讨论了最“著名”的原因,学习不可重复的噪声模式,就好像它们是真实的市场价格模式。当模型过于强大时,这可能会特别严重,这种情况称为过度拟合。一个更微妙但同样有问题的原因是训练(样本)数据中模式的代表性不足。如果训练交易系统的市场历史不包含未来可能遇到的每个可能的价格模式的足够的表示,那么当它们最终被遇到时,我们不能期望系统正确地处理被忽略的模式。因此,利用尽可能多的市场历史发展我们的交易系统符合我们的利益。
证明训练偏差的 TrnBias 计划
我的网站包含一个小型控制台应用程序的源代码,该应用程序演示了刚刚描述的原始移动*均交叉系统的训练偏差。读者可以很容易地修改它,以试验各种优化标准。完整的源代码在 TRNBIAS.CPP 中。
我不会在这里详细探讨这个程序,因为它被很好地注释了,并且对于任何想为自己的目的修改它的人来说应该是可以理解的。但是,我将简单地讨论它的操作。
从命令行调用该程序,如下所示:
TrnBias Which Ncases Trend Nreps
Which
指定优化标准:
-
0 =*均每日回报
-
1 =利润系数(赢的总和除以输的总和)
-
2 =原始夏普比率(*均回报率除以回报率的标准差)
Ncases
是交易天数。
Trend
是变化趋势的强度。
Nreps
是重复的次数,通常至少几千次。
该程序生成一组Ncases
对数日价格。价格由随机噪音加上每 50 天反转一次的交替趋势组成。这种趋势的强度被指定为一个小正数,可能是 0.01(弱)到 0.2(强)左右。0.0 的Trend
表示价格序列完全随机。然后,测试一整套长达 200 天的移动*均回拨测试,以找到短期和长期回拨的组合,从而获得最佳的样本内表现。用户指定判断该性能的标准。最后,使用相同的趋势强度,生成一组新的价格。它的分布与样本内集合相同,但它的随机分量不同。使用优化的短期和长期回顾,将移动*均交叉规则应用于该 OOS 数据集,并计算其*均日回报率。
这个过程重复Nreps
次,样本内和样本外的*均日收益率在重复中取*均值。样本内值减去样本外值就是训练偏差。这三个量被报告给用户。
如果你用这个程序做实验,你会发现一些和我在实际交易系统开发中看到的效果相似的效果。
-
如果您有大量的案例,选择优化标准的影响相对较小。事实上,所有这三种不同的方法都倾向于为大型数据集提供相同的最佳回顾,而不管趋势的强度如何。
-
如果数据集很小,优化标准对结果有很大的影响。
-
通过优化利润因子来获得最大的 OOS *均日收益是一个轻微但不普遍的趋势。我在现实生活的发展中也看到了同样的效果。
-
在我运行的几乎每个测试中,当优化*均每日回报时,*均每日回报的训练偏差最高(最差)。几乎可以肯定,这是因为除了间接的风险(损失)之外,*均日回报率并不考虑其他因素。利润因子和夏普比率都有利于一致、可靠的回报,使它们成为交易系统的最佳优化标准。此外,利润因素几乎总是有最小的训练偏差。这是我最喜欢的优化标准。
读者可能希望修改 TrnBias 程序,以纳入他们假设的价格模式类型、他们的交易系统规则和他们首选的性能标准,以研究他们情况下训练偏差的性质。
选择偏差
Agnes 领导着一家公司的交易系统开发部门。她手下有两个人,每个人都负责根据到目前为止的历史数据独立开发一个盈利的交易系统。很快,John 向她展示了出色的回溯测试结果,而 Phil 的结果虽然不错,但并不令人印象深刻。很自然地,她选择了约翰的交易系统,并用真钱来交易。
几个月后,他们的交易资本基本上消失了。全军覆没。约翰的完美系统失败了。阿格尼丝彻底地斥责了约翰,但她还是被解雇了,他们请来了玛丽来代替她。
Mary 检查了 John 的系统,并立即发现了问题:他使用了一个非常强大的预测模型,该模型在模拟市场数据中固有的噪音方面做得非常好。此外,因为 Agnes 给了这两个人到目前为止完整的市场历史,他们在开发他们的交易系统时都用到了。他们都没有花费任何精力去评估他们系统的样本外性能。因此,他们都不知道他们的交易系统有多好地捕捉了真实的市场模式,而不仅仅是模拟噪音。
在拍了拍他们的双手后,她告诉他们 Agnes 忽略的一个至关重要的原则,在这个原则中,他们是同谋:当从竞争系统中选择时, 总是将选择标准基于样本外结果 ,忽略样本内结果。
为什么呢?原因是,如果选择是基于结果,选择过程有利于过度拟合模型。如果模型 A 主要捕捉真实的市场模式,而模型 B 不仅捕捉这些模式,而且在捕捉噪音模式方面也做得很好,那么模型 B 将在结果上胜过模型 A 并被选中,只是在噪音不再重复用于真实交易时失败。
这个原则如此重要,以至于玛丽明智地选择保留最*一年的市场历史。她给了 John 和 Phil 截止到当前日期前一年的市场数据,并告诉他们再试一次。
一段时间后,他们都带着他们的系统来到她面前,自豪地炫耀他们惊人的成果。(这些家伙就是不学无术!)所以,她用约翰的交易系统,用她隐瞒的那一年的数据进行测试。还算体面。这让她很高兴,因为她刚刚观察到的结果是对约翰的系统未来所能做的真正公*、公正的估计。那一年的测试对他的系统开发没有任何作用,所以它不能影响他的选择或训练程序,因此它没有乐观的偏见。这正是她对交易系统的真实质量做出明智决定所需要的信息。
插曲:无偏到底是什么意思?
让我们暂时靠边站,对刚刚出现的术语无偏做一个简单直观的澄清。我们假设一个假想的宇宙,有无限多的约翰,在无限多不同的嘈杂的市场历史中运作,每个约翰都根据自己宇宙独特的嘈杂的市场历史发展自己的交易系统。本例中的无偏(通常也是如此),我们的意思是,*均而言,这些不同的约翰制作的交易系统的 OOS 结果既不会高估也不会低估实际预期的未来表现。由于宇宙间的随机变化,几乎可以肯定的是,任何单个约翰产生的交易系统都会高估或低估其 OOS 结果的预期未来表现。“不偏不倚”并不意味着而不是我们可以预期未来会有同样的表现。在一个随机的宇宙中,这种希望太大了。约翰的交易系统的 OOS 性能会高估或低估系统的实际预期未来性能。但是无偏确实意味着无论如何都不会有固有的偏见。由于已经讨论过的训练偏倚,样本内结果具有强烈的乐观偏倚。样本外结果没有这样的偏差。粗略地说,我们可以说约翰的 OOS 结果高估和低估了未来的表现。这是我们能做的最好的。
*#### 选择偏差,续
好了,足够的转移注意力;让我们回到正在进行的故事。我们有约翰的 OOS 演出。Mary 现在继续测试 Phil 开发的交易系统,这个系统基于她从这两个人那里得到的最*一年的数据。这是 OOS 的表现,就像约翰的表现一样,也是不偏不倚的,而且略胜约翰一筹。所以,她明智地选择了菲尔的系统进行交易。
我们现在来看这一节的关键点:公司现在交易的菲尔系统的 OOS 表现是乐观偏差!啊?这怎么可能呢?刚才,菲尔的 OOS 表现是他的系统的预期未来表现的公正措施。但是现在选择了交易,投入工作,那个业绩数字突然就偏了?那没有意义!
其实确实有道理。我们正在经历的被称为选择偏差。当玛丽检查了 OOS(约翰和菲尔)的表演并选择了更好的表演者时,它就开始发挥作用了。选择一个而不是另一个的行为引入了乐观偏见。玛丽刚刚测量的菲尔系统的 OOS 性能现在*均来说会高估他的系统的预期未来性能。
一眨眼的功夫,怎么会发生如此离奇的从不偏不倚到有偏不倚的转变?这是因为这两个竞争系统(约翰的和菲尔的)的 OOS 表现都受到两种不同效果的影响:真正的技巧和狗屎运。这两个系统无疑将基于轻微的(或很大的!)不同的正宗花样。与另一个系统相比,一个系统中的随机噪声更像该系统的真实模式。因此,在其他条件相同的情况下,选择更好的系统往往会有利于更幸运的系统。如果两个系统具有相等(尽管不可测量)的真实功率,那么更幸运的系统将具有更好的 OOS 性能,因此被玛丽选择。只有当他们真正的力量大相径庭,淹没了运气,真正更好的系统几乎肯定会被选中。
根据定义,噪音不会重复。任何有利于一个系统而不利于另一个系统的好运都将消失。只要我们在个体的基础上关注每一个系统,那些假想的宇宙中的好运和厄运就会达到*均,OOS 的表现就会是无偏的。但是,当我们比较两个或更多竞争系统的无偏性能并选择更好的系统时,运气不再*均;好运受到青睐,因此我们引入了选择偏差。这在现实生活中是巨大的。被警告。
现在很明显,如果 Mary 想要对她选择的交易系统的未来表现有一个公正的估计,她必须提供更多的数据。她需要拿出两年(??)的数据,而不是只保留一年(或者她想要的任何时间段)。她向 John 和 Phil 提供了截止到当前日期前两年的市场历史记录。当他们向她展示他们的系统时,她在他们培训年之后的数据年测试他们的系统,该数据年在当前日期之前一年结束。基于竞争系统在“第一个 OOS”年的表现,她选择了最好的系统。然后,她用最*一年的数据来检验这种选择,这一年可能被称为“第二个 OOS”年。这提供了对所选系统性能的无偏估计。这个估计不仅不受训练偏差的影响,而且也不受她从竞争者中选择最佳系统所导致的选择偏差的影响。
serbias 计划
在您跳过这一部分之前,请允许我鼓励每个人学习这一材料,即使是那些对使用或修改 SelBias 程序没有兴趣的人。原因在于,对选择偏差演示程序如何工作的描述将有助于强化前面章节中提出的有些违反直觉的观点。选择偏差的概念对许多开发人员来说是如此陌生,但又如此重要,以至于很难过分强调这个主题。即便如此...
我的网站包含一个小型控制台应用程序的源代码,它演示了刚才描述的原始移动*均交叉系统的选择偏差。读者可以很容易地修改它,以试验各种交易系统和优化标准。完整的源代码在 SelBias.cpp 中。
从命令行调用该程序,如下所示:
SelBias Which Ncases Trend Nreps
Which
指定优化标准:
-
0 =*均每日回报
-
1 =利润系数(赢的总和除以输的总和)
-
2 =原始夏普比率(*均回报率除以回报率的标准差)
Ncases
是交易天数。
Trend
是变化趋势的强度。
Nreps
是重复的次数,通常至少几千次。
该程序生成一组Ncases
对数日价格。价格由随机噪音加上每 50 天反转一次的交替趋势组成。这种趋势的强度被指定为一个小正数,可能是 0.01(弱)到 0.2(强)左右。0.0 的Trend
表示价格序列完全随机。
前面讨论的 TrnBias 程序采用了双边(多头和空头)交易系统。但是这部分的 SelBias 程序把它分成两个独立的交易系统,一个严格做多,另一个严格做空。
对于这两个竞争系统中的每一个,我们都测试了一套完整的测试移动*均回看,范围长达 200 天,以找到一个短期和长期回看的组合,给出每一个的最佳样本内性能。对于每个系统(仅长系统和仅短系统),分别找到这些最佳回顾。用户指定判断该性能的标准。
一组新的价格,使用相同的趋势强度,被生成。它的分布与样本内集合相同,但它的随机分量不同。这个数据集对应于上一节中提到的“第一 OOS”数据集。在 Mary-John-Phil 的例子中,这将是给 John 和 Phil 的数据之后的一年。针对两个竞争系统的移动*均交叉规则应用于该 OOS 数据集,对每个系统使用优化的短期和长期回顾。对于这个新的数据集,计算每个系统的*均每日回报,为我们提供两个系统的未来性能的无偏估计。
然后,我们生成第三个独立的数据集,之前称为“第二 OOS”数据集。在选择了较优的模型之后,在第三个数据集上评估两个竞争模型中在先前数据集上表现最好的那个,以提供性能的无偏估计。选择偏差是获胜模型在第二个(第一个 OOS)数据集上的性能减去其在第三个(第二个 OOS)数据集上的性能。
这个过程重复Nreps
次,两个竞争系统的样本内和样本外*均日收益、大 OOS 收益和选择偏差在重复中*均。每个竞争者的样本内值减去样本外值就是训练偏差。每个竞争者都有自己的训练偏差,但只有一个选择偏差。这些*均数量被报告给用户,同时还有选择偏差的 t 值。
向前行走分析
大多数交易系统开发人员都熟悉使用 walkforward 分析来估计未来的表现。尽管这种算法无处不在,我们还是在这里提出它,既是为了澄清任何误解,也是为了指出该算法最常用版本中的几个潜在缺陷。
walkforward 分析背后的思想是,给定一个历史数据集,我们模拟一个交易系统在该市场历史中实时执行(不知道未来)时的表现。换句话说,在任何一个特定的历史时刻,我们都有所有可利用的市场历史,直到并包括那个特定的时间,我们假装不知道那个时间以后的市场价格。我们使用特定时间的数据设计我们的交易系统(通常通过优化参数),然后测试这个交易系统在最*的未来时间段(即 OOS)的表现。这模拟了我们的系统在那个历史时期的真实生活中的表现。我们暂时把未来的表演藏在某个地方。然后,我们及时向前移动一切,重复这个过程,就像一个真正的交易者在不断更新交易系统以跟上不断变化的市场条件时所做的那样。当到达历史数据的末尾时,我们汇集所有的单个 OOS 结果,并计算我们想要的任何性能度量。该算法的最基本版本可以表述如下。稍后会出现更高级的版本。
-
将
OOS_START
设置为用户期望的测试开始日期栏。 -
基于期望的回望期的市场历史创建交易系统,该回望期刚好在到
OOS_START
之前的结束。 -
在从
OOS_START
开始的期望时间段NTEST
内执行交易系统。保存系统的性能。注意NTEST
不需要固定。例如,我们可能想在一个日历年内做日内交易,所以NTEST
将取决于被测试的一年内的交易天数。 -
如果还有更多市场数据,将
OOS_START
前进NTEST
并返回步骤 2。
当前面的算法完成时,我们检查在步骤 3 中保存的每次循环的性能结果。大多数人将单个这样的传球称为折叠,我们偶尔会使用这个术语。
请注意,由于该算法的构造方式,合并的 OOS 结果是连续的(没有缺失数据),并且它们按照如果该过程是真实生活而不是模拟时它们将会发生的顺序出现。这意味着,即使订单依赖的性能统计数据,如下降,可以合法计算。
即使在交易系统投入使用后,我们也可能想继续进行这种测试。通过这种方式,我们可以跟踪其正在进行的性能,以确定系统是否正在恶化(这是经常发生的事情!).在这种情况下,我们还有一个考虑。连续性假设第二步,交易系统的创建,可以足够快地进行下一个交易决策。如果我们在做日末交易,我们可能会在一夜之间重新训练系统。另一方面,如果我们在日间和夜间交易中进行价格点的日内交易,我们必须定义折价率,以便在市场空闲时(如周末)重新创建系统。实际上,这很少成为问题,因为我们几乎总能找到足够长的空闲时间来重新训练系统。但是关键的一点是如果我们希望进行持续的评估,我们必须使用在实时使用中强加给我们的相同粒度来执行开发走查分析。
为了清楚起见,假设我们的系统相对于交易速度来说训练得很慢,以至于在实际使用时必须在周末更新参数。在这种情况下,如果我们想要评估正在进行的绩效(总是明智的!),那么在开发期间,我们也应该使用周一到周五的块作为折叠来进行我们的 walkforward 分析。这样,实时结果可以与历史结果相媲美。
不明显的 IS/OOS 重叠造成的未来泄漏
开发交易系统的一个流行而强大的方法是基于市场历史建立一个由预测者和目标组成的数据集。预测指标通常是 RSI、趋势线斜率等指标。目标是对未来市场价格变化的一些度量,例如从当前价格到 10 天后价格的变化。数据集中的每个案例都包含市场中单个实例的所有预测值和目标值,如日棒线或盘中棒线。然后,开发人员将该数据集提供给建模算法,该算法可能像普通的线性回归一样简单,也可能像深度信任网一样复杂。当预测模型已经被训练时,通过计算当天的一组预测值的模型预测,每天执行交易系统。基于模型所做的预测,可能会也可能不会在市场中建立头寸。这让我们能够利用复杂的现代机器学习技术来驱动我们的交易系统。
当指标的回顾期超过一个柱时,这种方法会出现一个严重的潜在问题,事实上总是如此,和目标的展望期也超过一个柱,这通常是真的。当指标回顾一根以上的柱线时,它们具有序列相关性,因为相邻的数据库案例共享一个或多个市场价格观察结果。例如,假设我们有一个指标来衡量一个 50 棒回看周期的线性趋势线的斜率。当我们进入下一个案例时,两个相邻的案例共用 49 个小节。因此,趋势指标从一种情况到下一种情况变化很小。
目标也会产生同样的效果。假设我们的目标是从现在到十天以后的价格变化。当我们从一个案例前进到下一个案例时,这两个案例有九个共同的市场变化。这两种情况下的净市场变化在大多数时候非常相似。
现在考虑在分隔折叠的训练集的结尾和该折叠的测试集的开始的边界处发生了什么。如果指标和目标在中都具有序列相关性,那么训练集中的后期案例将与测试集中的早期案例相似,因为指标和目标都不会有太大变化。结果是关于测试集的信息已经泄漏到训练集中,这意味着假定公*的 OOS 测试现在是乐观的,因为在训练时,我们将有一些关于测试集的信息进入优化过程。
这种情况的含义是,我们必须通过省略训练集块末尾的一些情况来将训练集块与测试集块分开,这些情况将被未来的泄漏所污染。我们省略了多少?我们找到指标回顾和目标展望的最小值并减去 1。当然,如果指标有不同的回顾,我们认为指标回顾是所有指标中最大的。
例如,假设我们有三个指标,分别是 30、40 和 50 棒线。我们的目标有一个 80 小节的前瞻。(30,40,50)的最大值是 50。50 和 80 的最小值是 50。因此,我们必须从每个训练集块的末尾省略 49 个小节。
这个公式从何而来?推导它对读者来说是一个简单但有教育意义的练习。假设我们要测试一个从小节 100 开始的 OOS 集合。选择一个小的回顾和一个小的展望。对于 IS 和 OOS 区块,柱 99 处的潜在训练案例与柱 100 处的测试案例具有相同的价格吗?98 酒吧怎么样?在 IS 价格集或 OOS 价格集与第一个测试用例不再有共同价格之前,必须省略多少个结束用例?请记住,只有当既有一个指标,又有 IS 集和 OOS 集之间的目标股价历史时,我们才有问题,因为这就是测试集信息泄漏到训练集中的方式。如果一个或另一个(指标集或目标)对于两种情况是独立的,那么这些情况在 is 和 OOS 集之间没有共享偏见信息。
这里有两件事值得注意。首先,在几乎所有的实际情况下,指标回顾会超过目标展望,通常是很多。因此,前瞻是极限量。第二,如果目标前瞻只是一个小节,这是常见的情况,我们不必省略任何训练数据。在下一节中,我们将探讨仅预测一个小节的另一个优势。
多杆前视误差方差膨胀
在前面的部分中,我们看到,如果目标前瞻大于一个 bar,我们必须从训练集中移除最接*折叠边界的那些情况,以避免在应该是无偏的结果中出现灾难性的乐观偏差。在这一节中,我们将探讨多条前视的另一个问题,它有一个非常不同的解决方案。
偶然污染我们的市场数据的噪声中的随机变化将导致我们的步行前进性能数字也被随机变化污染;在汇集了所有 OOS 褶皱数据后,我们得到的性能数据,尽管如果我们做得对,是无偏的,但几乎肯定会高估或低估真实值。我们在第 125 页讨论术语无偏的含义时触及了这一点。自然,我们希望这个误差方差尽可能小。此外,负责任的开发人员会尝试用其他有用的信息来补充性能结果,例如,如果系统真的毫无价值,那么这么好的结果可能是通过随机的好运气获得的(一个 p 值)。我们甚至可能试图计算置信区间,或者进行从 283 页开始讨论的任何复杂的测试。
问题是,几乎所有我们想要进行的统计检验都要求检验所依据的观察值是独立的。(有一些测试不需要独立性,但是它们很难执行,并且通常价值可疑。)现在考虑当我们的前瞻大于一个小节时会发生什么。由于共享重叠的价格历史,相邻柱的目标值将紧密相关。因此,我们用来计算业绩统计的观察值(交易回报)并不是独立的。
这比只是模糊地“违反”各种统计检验的假设更严重。事实证明,这种违反是最糟糕的:测试变得反保守。这意味着,如果我们计算 p 值,计算出来的概率会太小,导致我们得出结论,我们的交易系统比实际情况要好。如果我们为了界定输赢而计算置信区间,那么得到的区间会太窄,真实区间可能比计算出来的要宽得多。
即使我们不进行任何统计测试(不负责任!)且只考虑无偏的 OOS 性能,我们仍然为使用多条前瞻而没有我们将很快描述的补救措施付出代价。造成我们所有问题的根本原因是,误差方差,即我们的无偏性能估计值围绕其真实值随机变化的程度,比单个交易回报独立的情况下要大。
当回报是独立的,并汇集成一个单一的性能统计,随机误差的回报往往会抵消。有些错误是积极的,有些是消极的,它们会相互抵消。但是当收益具有序列相关性时,他们取消的机会就少了。存在大量的正误差和大量的负误差,使得*滑消除更加困难。
结果是,即使 OOS 性能是无偏的,其令人烦恼的误差方差被夸大了。它对真实性能的过高估计或过低估计要比其他情况下更大。由于有很大的前瞻性,这种通货膨胀可能会很严重。在严重的情况下,误差方差可能大到使 OOS 性能估计几乎没有价值,尽管是无偏的。
解决这个问题的通常方法是使用仅一个条长的测试折叠,而不是将折叠推进一个条,而是通过前瞻来推进它们。这保证了 OOS 测试用例不会共享任何市场信息。例如,假设前瞻为 5,我们将在小节 100 开始 OOS 折叠。训练块将以条 95 结束,省略 4 个最*的情况以防止偏差。在对棒线 100 做出交易决定后,我们将把 OOS 折叠提前到棒线 105。
这种方法的另一个好处是,它模仿了大多数交易者在现实生活中的做法。大多数交易者不希望在前瞻期间继续建立他们的头寸,即使模型建议这样做。灾难性损失的风险太大了。
通用的向前行走算法
我们首先定义一些必须由用户指定的量。
-
LOOKBACK
是用于计算指标的价格历史(包括当前棒线)的棒线数量。 -
LOOKAHEAD
是用于计算目标的未来价格棒线的数量,不包括当前棒线。 -
NTRAIN
是交易决策所基于的预测模型的训练集中使用的案例数(在忽略任何最*的案例之前)。我们从价格历史中的当前棒线往回看的总距离是LOOKBACK
+NTRAIN
–2。实际培训案例数为NTRAIN
—OMIT
。 -
NTEST
是每个 OOS 测试块中测试用例的数量。 -
OMIT
是在LOOKAHEAD
大于 1 时,为防止乐观偏差而从训练集中忽略的最*训练案例的数量。 -
EXTRA
是除了NTEST
之外,为下一个折叠提前的箱子数。换句话说,每一个折叠都将被数据集中的NTEST
+EXTRA
个案例推进,每个案例对应一个价格条。
正如前面几节所讨论的,如果LOOKAHEAD
大于 1(这是我们应该尽可能避免的),如果我们要智能地向前行走,我们应该采取一些预防措施。
-
我们必须设置
OMIT
=min (LOOKAHEAD, LOOKBACK)
–1 以避免致命的乐观偏见。这一点至关重要。 -
如果我们要避免交易结果中危险的序列相关性,我们必须设置
NTEST
= 1 和EXTRA
=LOOKAHEAD
–1。单独的序列相关不会引入偏差,但它会增加影响我们的 OOS 性能数据的误差方差,并且它排除了大多数传统的统计测试。
一般的 walkforward 算法如下:
-
将
OOS_START
设置为用户期望的测试开始日期栏。如果要使用整个数据集,设置OOS_START
=NTRAIN
。 -
基于从
OOS_START
–NTRAIN
到OOS_START
–OMIT
–1 的案例的市场历史创建交易系统。 -
从
OOS_START
到OOS_START
+NTEST
- 1 执行交易系统。保存系统的性能。注意NTEST
不需要固定。例如,我们可能想在一个日历年内做日内交易,所以NTEST
将取决于被测试的一年内的交易天数。 -
如果还有更多市场数据(数据集中的案例),将
OOS_START
向前推进NTEST
+EXTRA
,然后返回步骤 2。
算法的 C++ 代码
文件重叠。我们很快将探讨的 CPP 包含了一个完全通用版本的 walkforward 算法的例子。下面是说明该算法的一段代码。我们将把它分成几个部分,分别解释每一部分。
完整的数据集在data
中。该矩阵包含ncols
列,最后一列是目标变量(通常是*期未来市场价格变化的度量),所有之前的列是预测值。这个矩阵有ncases
行,每行对应一根棒线或一个交易机会。我们将当前训练集的起点trn_ptr
初始化为数据集的起点。OOS 测试集从索引istart
开始,刚好经过组成训练集的用户指定的ntrain
案例。我们将在n_OOS
统计 OOS 病例。
trn_ptr = data ; // Point to training set, which starts at the beginning of the data
istart = ntrain ; // First OOS case is immediately past training set
n_OOS = 0 ; // Counts OOS cases as they are processed
主折叠环如下所示。我们不必预先计算折叠的次数,而是让它保持开放,当我们用完历史数据时就停止前进。
for (ifold=0 ;; ifold++) {
test_ptr = trn_ptr + ncols * ntrain ; // Test set starts right after training set
if (test_ptr >= data + ncols * ncases ) // No test cases left?
break ; // Then we are finished
在刚刚显示的循环开始时,我们将测试集指针设置为当前训练集开始之后的ntrain
个案例。我们也可以使用istart
来设置这个指针,但是我相信这个公式更清晰。如果测试集的开始超过了历史数据的结尾,我们就完成了。
对find_beta()
的调用是培训阶段,即将讨论。我们有ntrain
—omit
训练案例,从trn_ptr
开始。另外两个变量是由训练算法返回的优化参数。然后我们将nt
设为 OOS 块中测试用例的数量。这通常是用户指定的数量ntest
。但是最后一个 OOS 区块可能会更短,所以我们根据需要将其修剪回来。
测试循环对每种情况进行预测。如果预测是正面的,我们就做多,记录目标。否则,我们采取空仓(减去目标)。最后,我们推进训练和测试模块。
find_beta ( ntrain - omit , trn_ptr , &beta , &constant ) ;
nt = ntest ;
if (nt > ncases - istart) // Last fold may be incomplete
nt = ncases - istart ;
for (itest=0 ; itest<nt ; itest++) { // For every case in the test set
pred = beta * *test_ptr++ + constant ; // test_ptr points to target after this line
if (pred > 0.0)
oos[n_OOS++] = *test_ptr ;
else
oos[n_OOS++] = - *test_ptr ;
++test_ptr ; // Advance to indicator for next test case
}
istart += nt + extra ; // First OOS case for next fold
trn_ptr += ncols * (nt + extra) ; // Advance to next fold
} // Fold loop
依赖于日期的向前行走
基于日期执行前推分析是很常见的。例如,我们可能希望一次测试一年:我们在一个日历年的年底进行培训,并在下一年进行测试。然后,我们将培训和测试窗口提前一年,做同样的事情。这具有最小化模型必须被训练的次数的优点,当训练时间有问题时,这可能是好的。这也有助于直观地展示结果。可以使用刚刚显示的通用 walkforward 算法,根据测试年份中的小节数为每个折叠设置NTEST
。而且很容易设置OMIT
以防止乐观偏差。然而,如果我们要避免方差膨胀,我们必须使用一个一的LOOKAHEAD
,方差膨胀排除了大多数的统计检验。
如果我们必须让LOOKAHEAD
大于 1,并且我们还必须呈现年度或其他日期相关的前推结果,那么我们需要将每个测试周期分解成单棒测试(NTEST
=1),每个测试周期由LOOKAHEAD
分隔,并且将结果合并到每年中。如果为每个子文件夹重新训练模型,将获得最佳结果,但这不是必需的。
探索步行前进的失误
在本节中,我们使用一个小的控制台程序来研究当没有采取适当的措施来消除有害影响时,大于一巴的前视头的影响。这个程序叫 OVERLAP.CPP,完整的源代码在 OVERLAP.CPP 中,我们从调用参数表开始,然后详细解释程序的操作。我们将以一系列的实验来证明各种相关的问题。首先,从命令行调用该程序,如下所示:
-
nprices
是市场历史中市场价格(棒线)的数量。为了获得最准确的结果,这个值应该很大,至少为 10,000。 -
lookback
是用于计算指标的历史条形数。 -
lookahead
是用于计算target
的未来棒线数量。 -
ntrain
是交易决策所基于的预测模型的训练集中使用的案例数(在省略任何案例之前)。训练案例的实际数量将是ntrain
减去omit
。 -
ntest
是每个 OOS 测试块中测试用例的数量。 -
omit
是指当lookahead
大于 1 时,为防止偏差而从训练集中忽略的最*训练案例的数量。 -
extra
是除了ntest
之外,为下一个折叠提前的箱子数。如果lookahead
大于 1,那么ntest
应该是 1,而extra
应该是lookahead
减 1,如果我们要避免交易结果中危险的序列相关性的话。 -
nreps
是用于计算中位数 t 分数和后面描述的尾部分数的重复次数。为了得到准确的结果,它应该相当大,至少是 1001。
OVERLAP nprices lookback lookahead ntrain ntest omit extra nreps
首先,该程序计算的价格历史是随机游走的,完全不可预测。这意味着,*均而言,没有一个交易系统会提供零以外的预期回报。实际回报超过零的程度表明乐观偏见已经蔓延的程度。
生成价格历史后,将创建一个由单个指标和目标组成的数据库。该指标是价格历史在回望期的线性斜率。目标是未来的市场价格lookahead
棒线减去当前价格。数据库中的每个案例对应于价格历史中的一个条形。
现在开始向前遍历,从数据库中的第一个案例开始。我们使用数据库中的第一个ntrain
减去omit
的案例作为训练集来计算线性回归方程(斜率和截距),用于从单个指标预测目标。然后通过应用这个回归方程来处理 OOS 块中的测试用例,以预测目标。如果预测是正面的,我们就做多,如果预测是负面的,我们就做空。
这种原始模型背后的原理是,至少在某些时间段内,市场将处于趋势跟踪模式,这将导致回归方程选取最*的价格趋势和未来趋势的延续之间的关系。当然,因为这个模拟中的市场价格是随机游走的,这种情况不会发生,除非是随机的,所以这个交易系统的预期收益应该是零。
在该 OOS 块中的所有测试用例被处理之后,通过将训练和测试窗口向前移动ntest
加上extra
个用例来推进该折叠,并且为该下一个折叠重复训练/测试。这种情况一直持续到所有价格用尽。
刚刚描述的整个过程,从市场价格历史生成开始,重复nreps
次。对于每个复制,计算 OOS 交易结果的 t 值,并打印所有复制的 t 值中值。因为市场价格是随机游走的,我们期望这个中间值大约为零,但是我们将会看到不正确的前向游走结构将会导致乐观偏差。此外,对于每个复制,计算右尾 p 值(至少产生这种好结果的概率可能是完全靠运气获得的)。(实际上,为了简单起见,使用正常 CDF 代替 t CDF,但是当使用大量市场价格时,这是一个极好的*似值。)每当这个 p 值小于或等于 0.1 时,计数器递增。因为市场价格是随机游走的,我们预计这一事件会发生大约 0.1 次nreps
重复。打印观察到的时间分数。我们将看到,如果 walkforward 的结构不正确,这个相当重要的 p 值将比 0.1 更频繁地出现。
这里有几个实验,展示了使用数据库/模型方法时不正确的前推的后果。在所有这些实验中,我们使用以下参数:
nprices
= 50,000 使用长期价格历史可提供准确的结果。
lookback
= 100 这对相对结果几乎没有影响。
lookahead
= 10 任何大于 1 的值都表明存在问题。
这是相当不重要的。
nreps
= 10001 较大的值会降低随机误差对结果的影响。
提醒一下,该程序将打印两个结果,OOS 回报的中值(跨重复)t-score 和与 t-score 相关的 p 值小于或等于 0.1 的这些重复的分数。因为市场是真正的随机游走(不可预测),我们预计前者在 0.0 附*,后者在 0.1 附*。任何超出这些期望值的增长都是由于不恰当的向前走而导致的危险的乐观偏差。
实验 1:来自 IS/OOS 的乐观偏倚与大测试集 重叠
ntest= 50
省略 = 0
多余的 = 0
对于这个测试,我们使测试集的大小与训练集的大小相同,并且不采取措施来对抗由超过 1 的前瞻引起的问题。如此大的测试集(与训练集大小相同)通常不会在现实生活中进行,因为测试集中的后期观察结果与训练集相差太远,市场中的任何不稳定性都会降低可预测性。但是,当模型需要大量训练时间,而我们又没有计算资源来更频繁地重新训练时,这可能是必要的。
我们发现中位数 t 值是 5.35,这是一个严重的偏差,t 值在 0.1 水*上显著的重复比例是 0.920,这是一个荒谬的偏差。
实验 2:来自 IS/OOS 的乐观偏差与 1-Bar 测试集 重叠
ntest = 1
省略 = 0
多余的 = 0
这是理想的测试和实时情况,在每次使用后重新训练模型。当交易日棒线时,这通常是可行的;我们每天晚上重新训练模型,以便对第二天做出预测。
我们发现 t 值的中位数是 74.64(!),极端偏倚,t-得分在 0.1 水*显著的重复分数为 1.0,完全失败。为什么这种偏差比之前的实验严重得多?原因是,当我们在每个折叠中都有一个大的测试集时,随着案例从训练集进一步深入到未来,重叠价格的数量会减少,从而减少乐观偏差。但是,当我们只测试紧接在训练集之后的单个案例时,我们有最大可能数量的重叠价格。
实验三:乐观偏差来自 IS/OOS 重叠,完全处理
ntest = 1
省略 = 9
多余的 = 0
在这个实验中,我们探讨了从第 131 页开始描述的主题,当多条先行产生不明显的未来泄漏时的乐观 OOS 性能。回想一下,目标前瞻是 10 个小节,因此为了完全消除将来的泄漏,我们必须省略 10-1=9 个最*的训练案例。我们在这个测试中就是这样做的。
我们发现中位数 t-score 为-0.023,除了测试中的随机变化外,该值为零。因此,我们已经完全消除了 OOS 结果中的偏差。然而,t-得分在 0.1 水*显著的重复比例是 0.314。当 OOS 的结果没有偏见时,这怎么可能发生呢?这是因为第 133 页讨论的方差膨胀。我们将在实验 5 中探讨这个问题。
实验 4:乐观偏差来自 IS/OOS 重叠,部分处理
ntest = 1
省略 = 8
多余的 = 0
这个测试与之前的实验完全相同,除了我们几乎忽略了足够多的训练案例。我们需要省略九种情况,但我们只省略了八种。
我们发现中位数 t 值是 1.88,虽然不算大,但仍然是个问题。即使我们已经非常接*了,但是由于未能省略所需数量的病例而导致的欺骗仍然会引入危险的乐观偏见。此外,t-得分在 0.1 水*上显著的重复比例为 0.588,比先前的实验更差。
实验五:乐观偏差和方差膨胀,全权处理
ntest = 1
省略 = 9
多余的 = 9
在这个实验中,我们处理了多条目标前瞻中涉及的两个问题。回想一下,目标前瞻是 10 个小节,因此为了完全消除未来的泄漏偏差,我们必须省略 10-1=9 个最*的训练案例。此外,为了避免 OOS 交易结果的序列相关性引起的方差膨胀,我们必须增加 9 个案例。我们在这个测试中两者都做。
我们发现中位数 t 值是-0.012,除了测试中的随机变化之外,该值为零。因此,我们已经完全消除了 OOS 结果中的偏差。此外,t 分数在 0.1 水*上显著的重复比例是 0.101,这是我们在随机试验中所能预期的最完美的结果。
测试对非*稳性的鲁棒性
交易系统开发者的诅咒(好吧,反正是诅咒之一)是金融市场的不稳定性。几个月来具有高度可预测性的模式可能会突然消失。这可能是因为不断变化的经济环境,如异常高或异常低的利率。也可能是因为大型机构发现了这些可预测的模式,导致可预测性被套利而不复存在。不管原因是什么,重要的是我们要测试我们的交易系统对市场变化的承受能力。
应该注意的是,不同的交易系统对常见类型的市场变化确实有不同程度的稳健性。这通常是有意的。一些开发商故意设计对变化的条件有快速反应的交易系统,但也需要经常修改以跟上不断变化的市场模式。其他人设计的系统利用的模式,虽然往往不太突出,但在市场上存在多年甚至几十年。不管我们的选择如何,或者即使我们没有刻意选择,我们也需要知道,随着市场模式的演变,一个经过训练的模型能够保持多长时间的预测能力。
评估对非*稳性鲁棒性的一种有效方法是进行多次前向分析,每次分析都有不同的测试周期。例如,我们可能每晚重新训练一个日棒系统,只在第二天测试它。然后我们用两天的 OOS 时间测试同一个系统,每隔一天重新训练它。继续这个测试模式,延长测试周期,直到性能严重下降。
当我们绘制 OOS 性能与测试周期的关系图时,我们通常会在最短的测试周期(最频繁的重新训练)看到峰值性能。随着测试时间的延长,性能会下降。通常,开始时下降会很慢,然后直线下降,让开发人员大致了解系统必须多长时间重新培训一次。
一个更加敏感,但是稍微复杂一点的方法是,只根据每个测试折叠中最后一个条的来确定性能。这消除了早期较好结果的影响,尽管(较小的)代价是性能曲线中更多的变化。
交叉验证分析
walkforward 分析的一个主要缺点是它不能有效地利用所有可用的市场历史。对于每个前向折叠,所有超过 OOS 块末尾的信息都将被忽略。我们可以通过交叉验证来解决这个问题。我们的想法是,不仅仅使用在 OOS 测试块之前的训练数据,我们还将 OOS 测试块之后的数据包括在训练集中。这在不涉及时序数据的应用程序中非常有用。然而,当交叉验证应用于时间序列数据,如市场历史,几个微妙的问题可以咬我们。在本节中,我们将探讨这些问题。
IS/OOS 重叠不明显
如果你已经忘记了大于一根棒线的前瞻如何在向前遍历分析中导致未来泄漏的乐观偏差,请回顾从 131 页开始的资料。我将留给读者一个练习来说明,正如我们必须在 walkforward 分析中从训练集的末尾省略 min(回顾,前瞻)–1个案例一样,当我们进行交叉验证时,我们也必须从训练集的 OOS 测试块之后的部分的开头省略这么多案例。为了显示这一点,使用您在展示它时使用的相同技术来进行前向分析。
图 5-1 显示了五重交叉验证的工作原理。矩形的完整左右范围表示可用数据的历史范围。长矩形上方的四个散列标记描绘了五个折叠。在所示的文件夹中,我们正在测试中间的模块。
图 5-1
交叉验证中的保护缓冲区
如果我们的目标变量只有一个条形的预测,我们可以使用 OOS 测试集两边的所有数据作为训练数据。但是该图说明了具有更长前瞻的情况。因此,我们需要忽略测试集两侧的训练案例,作为保护缓冲区,以防止可能导致危险的乐观偏差的无意的 IS/OOS 重叠。
完全通用的交叉验证算法
在某些情况下,程序员可能会发现避免即将展示的算法中涉及的所有洗牌是最容易的。这可以通过将训练集、测试集和保护缓冲区的开始和停止边界直接合并到训练和测试代码中来实现。但这本身就很棘手。而且,它需要高度定制的训练和测试代码;固定的或通用的算法是不可能的。本节和下一节中显示的算法旨在将所有定型数据合并到一个连续案例数组中,并将测试数据合并到另一个连续块中。这大大简化了单独的训练和测试代码。
在本节中,我们以简单的算法形式陈述了一般的交叉验证过程,以提供一个概述。在下一节中,我们将看到阐明细节的 C++ 代码。如果不需要保护缓冲器(omit
=0),则算法很简单。但是,如果我们需要一个保护缓冲区,将训练数据压缩到一个连续的数据块中需要复杂的原地洗牌,或者保留数据集的单独副本,根据需要从源阵列复制到目标阵列。我们选择后一种方法,因为它不仅编程更简单,而且执行更快。
因此,如果omit
> 0,我们有两个数组。我们称之为 SRC 的数据库包含整个历史数据集。另一个称为 DEST,它是将被传递给训练和测试例程的数组。但是如果omit
=0,我们只使用历史数据的数组,对每个折叠进行适当的移动。在这两种情况下,istart
是当前第一个测试用例的索引(原点 0),而istop
比当前最后一个测试用例的索引大 1。符号m::n
是指从m
到n
的连续案例块,但不包括n
。算法如下:
istart = 0 First OOS test block is at start of dataset.
ncases_save = ncases ; We’ll temporarily reduce this, so must restore.
For each fold...
Compute n_in_fold and istop Number of test cases; one past end of test set.
if omit We need guard buffers.
copy SRC[istart::istop] to end of DEST This is the OOS test block.
if first fold The training set is strictly after the test set.
copy SRC[istop+omit::ncases] to beginning of DEST This is the training set.
ncases -= n_in_fold + omit This many cases in training set.
else if last fold The training set is strictly before the test set.
copy SRC[0::istart-omit] to beginning of DEST This is the training set.
ncases -= n_in_fold + omit This many cases in training set.
else This is an interior fold.
copy SRC[0::istart-omit] to beginning of DEST First part of training set.
copy SRC[istop+omit::ncases] to DEST[istart-omit] Second part of training set.
ncases -= n_in_fold + 2 * omit This many cases in training set.
else omit=0 so we just swap in place.
if prior to last fold We place OOS block at end; already there if last fold.
swap istart::istop with end cases
ncases -= n_in_fold This many cases in training set.
Train Training set is first ncases cases in new data matrix.
ncases = ncases_save Restore to full dataset (it was reduced for training).
Test Test set is last istop–istart cases in new dataset.
if (not omit AND not last fold) If we shuffled in place, unshuffle.
swap istart::istop with end cases swap OOS back from end.
istart = istop Advance OOS test set for next fold
.
通用算法的 C++ 代码
前面的算法旨在给出相对复杂的洗牌过程的粗略概述,该洗牌过程用于合并每个折叠的训练和测试数据,便于通用训练和测试算法的使用。但是该概述忽略了许多细节,现在将使用实际的 C++ 代码来介绍这些细节。
我们从一些初始化开始。在整个算法中,istart
是当前第一个 OOS 测试用例的索引,istop
比当前最后一个测试用例的索引大一。每次折叠后完成的 OOS 病例总数将在n_done
中显示,出于索引目的,这些病例将在n_OOS_X
中一次一个地计数。如果我们使用保护缓冲区(omit
> 0),那么我们需要保存案例总数,因为ncases
将减少到每个折叠所使用的训练案例的实际数量。
istart = 0 ; // OOS start = dataset start
n_done = 0 ; // Number of cases treated as OOS so far
n_OOS_X = 0 ; // Counts OOS cases one at a time, for indexing
ncases_save = ncases ; // Save so we can restore after every fold is processed
这是折叠环。此折叠中的 OOS 测试案例数是尚未完成的案例数除以剩余待处理的折叠数。
for (ifold=0 ; ifold<nfolds ; ifold++) { // Processes user's specified number of folds
n_in_fold = (ncases - n_done) / (nfolds - ifold) ; // N of OOS cases in fold
istop = istart + n_in_fold ; // One past OOS stop
下面的if
语句处理必须处理保护块的情况。首先,我们将当前的 OOS 测试集复制到目标数组的末尾,在那里进行测试。
if (omit) {
memcpy ( data+(ncases-n_in_fold)*ncols , data_save+istart*ncols ,
n_in_fold*ncols*sizeof(double) ) ;
如果这是第一个(最左边的)折叠,则该折叠的整个训练集位于 OOS 块的右边。将其复制到目标数组的开头。训练案例数是案例总数减去 OOS 集和保护块案例中的案例数。
if (ifold == 0) { // First (leftmost) fold
memcpy ( data , data_save+(istop+omit)*ncols ,
(ncases-istop-omit)*ncols*sizeof(double) ) ;
ncases -= n_in_fold + omit ;
}
如果这是最后的(最右边的)折叠,则整个训练集在 OOS 块之前。复制那些案例。
else if (ifold == nfolds-1) { // Last (rightmost) fold
memcpy ( data , data_save , (istart-omit)*ncols*sizeof(double) ) ;
ncases -= n_in_fold + omit ;
}
否则,这是一个内部文件夹。这里我们处理一个在前面显示的算法大纲中没有明确说明的问题。可能用户指定了如此多的折叠,以至于每个折叠都有一个微小的 OOS 测试集,甚至可能只有一个案例。然后,可能发生在测试集的一侧,在保护块被排除之后没有案例。我们必须解决这个问题。
else { // Interior fold
ncases = 0 ;
if (istart > omit) { // We have at least one training case prior to OOS block
memcpy ( data , data_save , (istart-omit)*ncols*sizeof(double) ) ;
ncases = istart - omit ; // We have this many cases from the left side
}
if (ncases_save > istop+omit) { // We have at least one case after OOS block
memcpy ( data+ncases*ncols , data_save+(istop+omit)*ncols ,
(ncases_save-istop-omit)*ncols*sizeof(double) ) ;
ncases += ncases_save - istop - omit ; // Added on this many from right
}
} // Else this is an interior fold
} // If omit
下面的else
块处理omit
=0 的情况:没有保护块。这就简单多了。我们甚至没有单独的源数组。一切都被调换了位置。对于每个折叠,我们将 OOS 测试集交换到数组的末尾。在一个折叠的训练和测试完成后,数据被交换回原来的方式。注意,对于最后一个(最右边的)折叠,测试集已经在末尾,所以我们不交换。
else {
// Swap this OOS set to end of dataset if it's not already there
if (ifold < nfolds-1) { // Not already at end?
for (i=istart ; i<istop ; i++) { // For entire OOS block
dptr = data + i * ncols ; // Swap from here
optr = data + (ncases-n_in_fold+i-istart) * ncols ; // To here
for (j=0 ; j<ncols ; j++) {
dtemp = dptr[j] ;
dptr[j] = optr[j] ;
optr[j] = dtemp ;
}
} // For all OOS cases, swapping
} // If prior to last fold
else
assert ( ncases-n_in_fold-istart == 0 ) ;
ncases -= n_in_fold ;
} // Else not omit
/*
Train and test this XVAL fold
When we prepared to process this fold, we reduced ncases to remove
the OOS set and any omitted buffer. As soon as we finish training,
we restore it back to its full value.
*/
find_beta ( ncases , data , &beta , &constant ) ; // Training phase
ncases = ncases_save ; // Was reduced for training but now done training
test_ptr = data+(ncases-n_in_fold)*ncols ; // OOS test set starts after training set
for (itest=0 ; itest<n_in_fold ; itest++) { // For every case in the test set
pred = beta * *test_ptr++ + constant ; // test_ptr points to target after this
if (pred > 0.0) // If predicts market going up
OOS[n_OOS_X++] = *test_ptr ; // Take a long position
else
OOS[n_OOS_X++] = - *test_ptr ; // Take a short position
++test_ptr ; // Advance to indicator for next test case
}
/*
Swap this OOS set back from end of dataset if it was swapped there
*/
if (omit == 0 && ifold < nfolds-1) { // No guard buffers and prior to last fold
for (i=istart ; i<istop ; i++) { // This is the same code that swapped before
dptr = data + i * ncols ;
optr = data + (ncases-n_in_fold+i-istart) * ncols ;
for (j=0 ; j<ncols ; j++) {
dtemp = dptr[j] ;
dptr[j] = optr[j] ;
optr[j] = dtemp ;
}
}
}
istart = istop ; // Advance the OOS set to next fold
n_done += n_in_fold ; // Count the OOS cases we've done
} // For ifold
在前面的代码中,请注意,我们使用的“模型”与第 138 页详细讨论的重叠程序中使用的“模型”相同。子程序find_beta()
是训练阶段,使用data
中的前ncases
个案例计算一个线性函数,用于预测下一个数据值(下一个案例的价格变化)。在每个折叠的 OOS 测试阶段,我们通过测试集。对于测试集中的每一个案例,我们都预测了即将到来的市场变动。如果预测是正面的,我们就做多,如果是负面的,我们就做空。这些事实对于当前的讨论并不重要,因为这里的重点是交叉验证交换。只是要知道在所有的交换中,什么时候进行训练和测试。
交叉验证可能存在悲观偏见
人们普遍认为交叉验证产生了对总体性能的无偏估计。乍一看,这是有道理的:我们总是在测试一个模型,这个模型是根据独立于测试数据的数据进行训练的(假设在需要的时候使用了适当的保护缓冲)。但是交叉验证中微妙的问题是每个训练集的大小。每个折叠中的训练集小于整个数据集,而当模型投入使用时,我们通常会使用整个数据集进行训练。当我们有一个较小的训练集时,模型参数估计没有用整个数据集训练时准确。当然,模型参数估计不太准确意味着模型的准确性会降低,这意味着*均而言 OOS 性能较差。因此,在其他条件相同的情况下,我们可以预期交叉验证会略微低估当我们最终使用整个数据集进行训练,然后将模型投入使用时所获得的性能。
交叉验证可能存在乐观偏差
如果数据是非*稳的,这在市场交易应用中是很常见的,这种非*稳性可能是交叉验证中乐观偏差的来源。这个想法是,通过将未来市场数据包括在训练集中,即使个别情况被适当地排除,我们也为训练算法提供了关于数据的未来分布的有价值的信息,这些信息在现实生活中是不可用的。
举个简单的例子,假设你的历史数据自始至终都在稳步增加波动性。在 walkforward 分析中,以及在现实生活中,每个测试集(以及现实生活中的交易时段)的波动性都将超过训练集中的波动性,这可能是有问题的。但交叉验证中的众多内部测试折叠将在用来自未来和过去的数据训练的模型上进行测试,从而提供各种包含测试集中的波动性的波动性示例。这是未来泄露的一种微妙形式,即使没有实际案例共享。
交叉验证不能反映现实生活
从前面两节可以明显看出,当涉及到模拟现实生活时,交叉验证与 walkforward 分析相比是非常可疑的。当然,交叉验证确实允许使用比前向遍历分析更多的训练数据,特别是在早期折叠中,当前向遍历分析被迫使用贫乏的历史数据时。事实上,由于这个原因,walkforward 分析可能比交叉验证有更严重的悲观偏见。另一方面,大多数开发人员使用与用于训练最终产品模型的训练集大小相等的训练集来执行前向遍历分析。这是因为他们不愿意跨越太宽的历史时期,因为不稳定可能包含太多的市场机制。在这种常见的情况下,交叉验证的数据优势就消失了。一旦这种优势消失,就没有动力去容忍前一节中讨论的那种微妙的未来泄漏,其中向训练算法提供了未来非*稳性问题的暗示。因此,我不建议在交易系统开发中进行交叉验证分析,除非是在非常特殊的情况下。
算法交易的特殊注意事项
首先,我们先明确一下算法交易的含义。最*的大部分讨论都集中在日益流行和强大的基于模型的交易。在基于模型的交易中,我们建立一个预测者和目标的数据集,然后训练一个强大的模型来预测目标,给定交易机会的预测者。这与更古老、更传统的算法交易形成鲜明对比,在传统的算法交易中,严格定义的算法会即时做出交易决策。算法交易的一个古老法则是均线交叉系统:当短期均线高于长期均线时,我们做多;反之,我们做空。为了训练这样一个系统,我们找到了短期和长期的回顾来优化一些性能指标。我们现在调查算法交易系统中不明显的未来泄漏的潜在致命问题。
*回想一下从 131 页开始的讨论,对于前向走查分析和交叉验证,我们可能需要从训练集中删除一个保护缓冲区,在它接触测试集的地方。移除的事例数比计算模型训练数据库时使用的回望和前瞻距离的最小值小 1。
对于基于模型的交易,几乎总是回望距离超过前瞻距离,通常达到相当大的程度。我们可以回顾历史,寻找数百根棒线来计算趋势、波动和更复杂的指标。但是,当我们在市场上建仓时,我们通常最多持有几根棒线,这样模型就可以快速响应不断变化的市场条件。
但是对于算法系统,反过来往往是正确的,有时到了必须假设向前看的距离是无限的程度!例如,假设我们的交易系统按照以下规则运行:如果一条短期均线(在当前的这根棒线上)越过了一个高于长期均线 2%的阈值,我们就建立一个多头头寸。我们保持这个位置,直到短期均线穿越长期均线下方。在这个示例系统中,需要注意的关键点是我们不知道该头寸将开放多长时间。
让我们检查一下这个系统的步行测试。假设我们为长期移动*均线回看任意设置了 150 根棒线的上限。训练后,我们很可能会发现实际的回望比这要小,但它可能是如此广泛,所以我们必须做好准备。
前瞻呢?不幸的是,*仓规则可能在进场后几根棒线就生效了,或者我们可能在 1000 根棒线后还在我们的位置上。我们只是事先不知道。
这意味着,与基于模型的交易不同,在基于模型的交易中,前瞻几乎总是决定保护缓冲区的大小,而对于开放式算法交易,往往是回顾决定保护缓冲区的大小,这通常会大得令人沮丧。
追查这个例子将澄清情况。假设我们在这个折叠中的训练块的最后一个小节,比如小节 1000。为了找到最佳的短期和长期回顾,我们尝试了大量的候选对,甚至可能是每一对可能的短期和长期回顾。对于每个候选对,我们从训练块中最早的可能棒线开始,我们可以为其计算长期移动*均值。我们评估进场规则,如果出场规则通过,我们就建仓,直到出场规则生效。我们穿过训练区,按照规则进行交易。当开市过程到达 1000 点时,我们停下来计算这个短期/长期回顾对的表现。然后,我们对不同的回看对重复该过程。最终,我们有了在训练中表现最好的回顾对。
然后我们去 1001 小节,这是 OOS 测试集中的第一个小节。我们使用先前确定的最佳回顾来评估进场规则,并相应地采取行动。如果测试集的大小超过一根棒线,我们对下一根棒线重复,累积整个测试集的净性能。
敏锐的读者已经注意到,我们忽略了这个算法的一个重要方面:在训练过程中,当我们到达折叠训练块的末尾时,我们该如何处理这个位置?我们至少有五种方法可以处理训练/测试边界附*的问题,其中四种是好的,一种是灾难性的。
-
如果一个位置在训练块的最后是开放的,我们让它开放并继续前进,只有当关闭规则触发时才关闭它。 这提供了一个诚实的结果,在现实生活中会获得的利润。但是,假设在训练区的最后一根棒线 1000 开仓,这是一个非常有利可图的交易。训练算法将有利于捕捉大交易的回顾。现在考虑在 OOS 测试集中的第一个小节 1001 发生了什么。这个试验将与先前的棒线分享许多过去的价格历史,比最佳长期回顾少一个棒线。因此,它几乎肯定会打开一个交易。此外,这种交易将分享在训练阶段产生巨大利润的相同的未来棒线,因此它将非常有利可图。训练期和测试期之间的这种过去和未来的价格共享是严重的未来泄漏,它将产生重大的乐观偏差。别这么做。
-
当到达训练块的末端 时,强制训练算法关闭并标记位置。这消除了未来的泄漏,使交易系统与现实生活中所能达到的一致,因为没有未来的信息参与训练。但它确实扭曲了训练期结束时的交易,因为它以不同于训练期早期*仓的方式*仓,而训练期早期*仓不太可能过早*仓。这可能会也可能不会对最佳回看对的计算产生不利影响。这当然值得深思。
-
修改*仓规则,以*仓已开仓的指定数量的棒线,并使用该大小的保护缓冲区。 在讨论的例子中,我们可以将*仓规则定为“我们持有头寸,直到短期移动*均线穿越长期移动*均线下方,或者头寸已经开仓 20 根棒线。”然后,在训练期结束前,当我们通过这么多栅栏时,我们就停止建立新的仓位(有一个保护缓冲)。这也防止了未来的泄漏,并且与现实生活中可能实现的一致。与方法 2 相比,它的优势在于所有交易都遵循相同的规则,这避免了优化过程中的失真。但除非杠杠限制非常大,否则这可能是对开发商交易假设的不受欢迎的侵犯。
-
按照方法 1,在训练期结束后自由推进未*仓交易。然而,当我们到达训练期结束时,停止开仓(保护缓冲区)比最大回看值 少 1。在我们当前的例子中,我们可能开仓的最后一根棒线是 1000-(150–1)= 851。这是安全的,因为当我们从条 1001 开始测试时,我们将检查条 852 到 1001。因此,在训练期间作出进入决定的价格和作出测试进入决定的价格是完全不相关的。尽管避免了将来的泄漏,因此提供了无偏见的结果,这种方法有哲学上的烦恼,即它没有模仿现实生活;我们在培训过程中获取培训期结束后的价格。然而,这与其说是一个实际问题,不如说是一个哲学问题。
-
使用下一节的“单条前瞻”方法。
哪种方法最好?看情况(当然!).出于几个原因,我倾向于方法 3。所有的交易都遵循同样的规则,不管他们是在训练期的早期还是晚期。(方法 2 违背了这个美好的性质。)它不像方法 4 那样窥视未来,即使方法 4 的展望未来不会引入有害的未来泄漏。但也许最重要的是,在我多年的工作中,我发现自动交易系统在远离开盘价时会很快失去准确性。如果交易在开仓后没有达到或至少没有接*目标,它很快就会变成一次失败,也许会赢,也许不会。因此,通过引入头寸开放时间限制,我们减少了随机性的影响。
在一种情况下,方法 4 可能优于方法 3。这两种方法都要求我们在训练期结束前停止开仓。在方法 3 中,我们失去了交易时间限制,而在方法 4 中,我们失去了回顾。可能是我们的交易计划需要很长时间交易才能开市。以我的经验来看,这不是一件好事,但其他开发者可能会有不同看法。如果所需的时间比回望时间长,方法 4 会比方法 3 失去更少的交易机会。尽管对方法 4 在培训期间展望未来感到有些不舒服,但在这种情况下,我们可能会认为方法 4 是更好的选择。
将未知前视系统转换为单杆
我们刚刚探索了四种不同的方法来处理训练/测试边界附*的边界区域,其中三种是实用且有效的。我们现在介绍第五种方法,这种方法有时可能更复杂,但它完全避免使用任何保护缓冲区,因此增加了每个训练折叠的有效大小。它确实像方法 2 一样扭曲了训练结束时的交易,但是以一种更无害的方式。此外,它产生了一系列长的连续的单棒线回报,而不是更少的多棒线回报。这是执行 CSCV 优势测试和后面描述的其他几个程序所必需的。
为了实现这种转换,我们将交易规则修改为一系列单棒线交易,第一笔交易根据开盘规则开盘,随后的交易只是前一棒线头寸的延续。换句话说,假设我们期望的交易规则是在当前没有开仓(防止同时开仓交易)且OpenPosition
条件为真时开仓,然后在ClosePosition
条件为真时*仓。修改后的规则要求我们在每个棒线收盘时执行以下操作:
-
如果没有职位空缺
-
如果
OpenPosition
为真,开仓延伸通过下一根棒线 -
否则
-
*仓并记录该棒线的交易回报
-
如果
ClosePosition
为假,则重新打开刚刚*仓的同一仓位
只有在使用需要明确开仓和*仓以记录交易的商业软件时,才需要这种复杂性。当然,如果你正在编写自己的软件,那就简单多了:只需记录一笔公开交易的每根棒线的市值回报!
这通常是最好的方法,因为它提供了最佳的回报粒度。这对于稳定的利润系数计算很重要,它可以实现更准确的提取计算(本质上是对每个棒线进行盯市),并且对于本文其他地方描述的一些最强大的统计测试(CSCV)来说,它是强制性的。请认真考虑。一个实际的例子,用 C++ 代码,将出现在 198 页。
无界回送可能会微妙地发生
我们在前面的方法 4 中看到,交易机会的数量随着交易决策的回顾而减少。当我们有一个开放式交易系统时,我们倾向于使用这种方法,我们不想对交易保持开放的时间施加限制。但在这种情况下,我们必须小心,我们没有回望,至少在理论上,是无界的。如果我们不能在回望上建立一个牢固的界限,一个不是不切实际的大的界限,那么我们不能使用方法 4。
我们的回望怎么会是无限的?一个显而易见的方法是,如果我们的决策计算的某个组件有无限的回顾。例如,我们一直在谈论均线交叉系统,其中均线的回看是有界的。很好。但是,如果我们使用指数*滑或递归滤波器来进行长期和短期*滑,会怎么样呢?这种过滤器的价值是基于一直追溯到市场历史中第一个价格的数据来计算的。诚然,真正早期价格的贡献可能非常小。但是请记住,当涉及到市场交易时,看似无害的偏见来源会产生令人震惊的严重影响。
无界回顾的一个更微妙的来源是当交易决策基于先前的交易决策时。例如,我们的系统可能包括一个安全阀,如果连续四次交易失败,它会关闭一个月的所有交易。现在,对当前棒线的回顾回到之前的交易,以及之前的交易,以此类推。
或者考虑这些进场和出场规则:如果一些严格定义的频繁循环的条件为真,我们就开仓和我们目前没有开仓交易。当其他严格定义的条件成立时,我们关闭交易。在这种情况下,我们当前的交易决策取决于我们是否在之前的机会进行了交易,这反过来又取决于之前的机会,无限期。回望是无限的。
怀疑论者可能会嘲笑这个概念。我不知道,因为在我职业生涯的早期,这个问题让我深受其害,我不再低估它的影响。
比较交叉验证和 Walkforward: XVW
在第 138 页,我们介绍了重叠计划,以探讨不明显的 IS/OOS 重叠所引入的偏差。这里我们在 XvW 程序中扩展这个程序,XvW 程序的操作类似,但它的主要目的是演示完全相同的交易系统的 walkforward 和交叉验证分析之间可能存在的巨大差异。请放心使用这个程序(完整的源代码在 XvW。CPP)为模板,用自己的交易系统思路来探讨这种现象。
下面是调用参数列表。程序的大部分操作在第 138 页开始的部分有详细描述,所以我们在这里省略多余的细节。从命令行调用该程序,如下所示:
-
nprices
是市场历史中市场价格(棒线)的数量。为了获得最准确的结果,这个值应该很大,至少为 10,000。 -
是每 50 根棒线反转的趋势强度。趋势为 0.0 意味着市场价格序列是随机游走的。
-
lookback
是用于计算指标的历史条形数。 -
lookahead
是用于计算目标值的未来棒线数量。 -
ntrain
是交易决策所基于的预测模型的训练集中使用的案例数(在省略任何案例之前)。训练案例的实际数量将是ntrain
减去omit
。 -
ntest
是每个 OOS 测试块中测试用例的数量。 -
nfolds
是交叉验证折叠的次数。 -
omit
是指当lookahead
大于 1 时,为防止偏差而从训练集中忽略的最*训练案例的数量。理想情况下,它应该比前瞻值小 1。 -
nreps
是用于计算几个 t 分数和后面描述的尾部分数的重复数。为了得到准确的结果,它应该相当大,至少为 1000。 -
seed
是随机种子,可以是任意正整数。这有助于用不同的种子重复测试以确认结果。
XvW nprices trend lookback lookahead ntrain ntest nfolds omit nreps seed
正如从第 137 页开始所描述的,该程序反复生成市场历史。这个 XvW 程序和 OVERLAP 程序之间的一个区别是 OVERLAP 总是产生随机游走,而 XvW 可以有选择地产生具有用户指定的每 50 根棒线反转的趋势程度的价格历史。这在市场价格中引入了一定程度的可预测性,产生了正的*均回报。创建一个数据集,该数据集由每个条形的预测值和目标值组成。一个简单的线性回归模型通过向前遍历和交叉验证测试进行测试。完成后,将打印类似如下的一行:
Grand XVAL = 0.02249 (t=253.371) WALK = 0.00558 (t=81.355) StdDev = 0.00011 t = 150.768 rtail = 0.00000
这些信息是:
-
*均 OOS 回报和交叉验证的相关 t 值
-
*均 OOS 回报率和相关的 t 值
-
两种方法之间差异的标准差、该差异的 t 得分及其右尾 p 值
如果将趋势指定为 0.0,产生一个纯随机游走,则除了自然随机变化之外,所有 t 分数都将是无关紧要的。当你增加趋势时,t 值会迅速变得显著。前向走查和交叉验证之间的差异的 t 分数高度依赖于后向看、前向看,并且在某种程度上依赖于折叠的数量。这个演示的主要收获是,在几乎所有的实际情况下,走查和交叉验证分析都会产生明显不同的结果,而且往往相差甚远。
计算对称交叉验证
我已经指出(用多种理由)我不赞成交叉验证市场交易系统的性能分析。然而,有一个特殊形式的交叉验证的有趣应用,我发现它经常是有用的。该应用程序的灵感来自于 2015 年 David H. Bailey 等人的一篇引人入胜的论文“回测过度拟合的概率”。它可以在互联网上广泛免费下载。
计算对称交叉验证(CSCV)在很大程度上或完全消除了普通 k-fold 交叉验证的一个方面,这在某些情况下可能是有问题的:不相等的训练集和测试集大小。除非我们只使用两次折叠(由于不稳定性,通常不推荐),否则每次折叠的测试集将比训练集小得多。在极端情况下,当我们使用保持一出交叉验证时,每个测试集由一个单独的案例组成。通常,我们将所有 OOS 回报集中到一个与原始数据集大小相同的测试池中,因此没有问题。但是偶尔我们可能想要计算每个折叠的 OOS 数据的一个单独的性能标准,也许得到一个折叠到折叠变化的概念。一些标准,尤其是那些涉及比率的标准,会受到小集合的影响。例如,夏普比率要求我们除以样本中回报率的标准差。如果样本很小,这个分母可能很小,甚至为零。如果样品只有一个箱子,我们根本做不到。利润因子(盈利除以亏损)也需要大型数据集,涉及支出的指标也是如此。
CSCV 的工作原理是将单个交易回报(几乎总是提前一个棒线的回报)分成偶数个大小相等或几乎相等的子集。然后,将这些子集以各种可能的方式组合起来,使其中一半成为训练集,另一半成为测试集。例如,假设我们将收益分成四个子集。我们将子集 1 和 2 组合成一个训练集,并将 3 和 4 组合成相应的测试集。然后我们把 1 和 3 组合成一个训练集,我们把 2 和 4 组合成相应的测试集。我们重复这种重组,直到用尽了所有可能的排列。
应该清楚的是,除非分区的数量很小,并且返回的数量远不是分区数量的整数倍,否则所有训练集和测试集的大小将几乎相等,每个大约是返回总数的一半。
我们暂时离题强调一下,这种划分是在单个棒线回报上进行的,而不是在价格数据上。例如,考虑我们良好的旧均线交叉系统,假设我们已经指定了计算均线的短期和长期回顾。我们不分割价格历史,因为重组会产生致命的不连续性,这会对移动*均线计算造成严重破坏。相反,我们从头到尾处理整个市场历史,跟踪每根棒线的回报。这组单个棒线回报是分区的。
那么,每个训练集的模型优化是如何完成的呢?不幸的是,CSCV 不允许我们使用任何“智能”训练算法,即使用先前试验参数集的性能标准来指导未来试验参数集的选择。因此,举例来说,我们不能使用基因优化或爬山。每个试验参数集或其他模型变量必须独立于先前试验获得的结果。因此,要么我们将几乎总是使用大量随机生成的模型参数,要么我们将在有效参数空间中进行彻底的网格搜索。在评估每个试验参数集的一些性能度量之后,我们选择在训练集中具有最佳性能的参数集。
CSCV 算法:直觉和一般陈述
让我们回顾一下目前我们所掌握的情况。我们已经创建了(通常是大量的)模型参数集的候选集。例如,如果我们有一个移动*均交叉系统,一个试验参数集将由一个长期回顾和一个短期回顾组成。这些众多的参数集可能是随机生成的,也可能是在网格搜索中生成的。
对于每个试验参数集,我们在整个可用的市场价格历史上评估交易系统。我们必须指定一个固定的粒度来评估回报。这个粒度通常是每个棒线:对于每个棒线,我们计算该棒线提供的头寸(多头/空头/中性)对净值的贡献。但不必是每个酒吧;日内交易可以是每小时,日内交易可以是每周,等等。但是,粒度越细越好。重要的是,粒度的定义方式要使我们能够同时获得每个竞争系统的利润数字。这几乎从来都不是问题;我们只是评估每个竞争系统在相同时间点的利润变化(例如每个酒吧或每个周五的一周)。
为了简单起见,从现在开始,我假设我们正在评估每根棒线的回报,理解粗粒度是合法的,尽管不太理想。当我们评估了每个棒线的每个交易系统(每个参数集)后,我们可以用矩阵来表示这些回报。矩阵的每一行都对应一个交易系统(参数集),其逐根棒线的回报横跨该行。我们的矩阵中每个参数集有一行,交易系统活跃时有多少条线就有多少列。(这是[Bailey 等人]文章中矩阵的转置,但这样做计算效率更高。)
请注意,由于交易决策的回顾和交易决策的单棒回报评估的前瞻,我们的回报棒线实际上总是比价格历史棒线少。例如,假设我们需要最*的 10 根棒线来做交易决定。我们将失去 10 根棒线的历史价格。
如果我们现在想要找到整个可用历史的最佳参数集(与实现 CSCV 算法相反),我们为这个返回矩阵的每一行分别计算我们的优化标准,并且查看哪一行(参数集)产生最佳标准。例如,如果我们的业绩标准是交易系统的总回报,我们只需找出每行的总和,然后选择行总和最大的系统。如果我们的标准是夏普比率,我们将计算每行的退货数量,并找到具有最大值的行,依此类推。它告诉我们最佳参数集。
为了实现 CSCV 算法,我们将这个返回矩阵的列分成偶数个子集,如前所述。这些子集将被重新组合,其中一半定义一个训练集,剩下的一半是 OOS 测试集。所有可能的组合都会被处理。现在,考虑一个这样的组合。
我们现在为每一行计算两个标准,一个是混合训练集的性能标准,另一个是混合测试集的性能标准。例如,假设我们的标准是每根棒线的*均回报率。对于每一行(交易系统),我们将组成训练集的那一行的列相加,然后除以这些列的数量,得到该交易系统的每根棒线的*均回报率。同样,我们将组成测试集的列相加,然后除以这些列的数量,得到每根棒线的 OOS *均回报率。当我们对每一行都这样做时,我们有两个向量,一个用于训练集,一个用于测试集,每个向量都有与我们的竞争交易系统(参数集)一样多的元素。
为了找到用于单个训练/测试划分的最佳 IS 交易系统,我们简单地在其性能向量中定位具有最佳性能的元素。然后我们检查 OOS 向量中相应的元素。这是在这种特殊的分割中,IS-optimal 交易系统所达到的 OOS 性能。
这是 CSCV 算法的关键部分:我们考虑所有交易系统的 OOS 表现。如果我们的模型和参数选择程序是真正有效的,我们可以预期,相对于次优系统的 OOS 性能,次优模型也将具有更好的 OOS 性能。毕竟,如果这个模型优于样本内的竞争对手和,它确实捕捉到了真实的可预测市场模式,那么它通常应该很好地利用样本外的市场模式。我们设定了一个相当低但合理的标准来定义我们所说的相对良好的样本外表现:IS-best 系统的 OOS 表现应该超过其他系统的 OOS 表现的中值。
考虑一下,如果模型没有价值,我们会期待什么;它未能捕捉到任何真实的市场模式:没有理由指望表现最好的信息系统也将是更出色的 OOS。最佳信息系统的相对 OOS 性能将是随机的,有时优于其他系统,有时表现不佳。我们预计这个“最好”的系统有 50%的概率高于 OOS 性能的中值。但是,如果这个模型很好,在预测市场运动方面做得很好,我们可以预计它在 OOS 的表现也会很好,至少在大多数时候是这样。
我们如何估计最佳信息系统的 OOS 绩效高于 OOS 绩效中位数的概率?当然是组合对称交叉验证!回想一下前面的讨论,我们将形成子集的每一种可能的组合,将其中一半放在训练集中,另一半放在测试集中。对于每个这样的组合,我们执行刚才描述的操作:找到最佳的 IS 系统,并将其 OOS 性能与其他系统进行比较。计算 IS-best 的 OOS 性能超过其他公司的中值的次数。这些操作不是独立的,但每一个都是无偏的。因此,如果我们将优秀 OOS 性能的计数除以测试的组合总数,我们就有了一个合理的无偏估计,即一个经过训练的系统的 OOS 性能在模拟中超过其竞争对手的中值的概率。
我说合理地无偏,因为有两个偏差来源,前面讨论过,要考虑。首先,CSCV 的每个训练集是完整数据集的一半大小,这导致了与用整个数据集进行训练相比的悲观偏见。参见第 150 页。此外,如果市场价格(以及回报)是不稳定的,那么与现实生活中可能达到的表现相比,任何类型的交叉验证都可能有轻微的乐观偏差。另见第 150 页。
最后,应该注意的是,为了避免无意中的 IS/OOS 重叠(第 131 页),我们几乎总是采用一个小节的前瞻,这就是我在本书中提出的。重组算法可以被修改以收缩训练段,但是这种修改将是麻烦的,并且在这种情况下通常是不值得的。
我们现在准备对刚才直观描述的算法做一个简短的陈述。
Given:
n_cases: Number of cases (columns in returns matrix), ideally a multiple of n_blocks
n_systems: Number of competing systems (rows in returns matrix)
n_blocks: Number of blocks into which the n_cases cases will be partitioned (even!)
returns: n_systems by n_cases matrix of returns.
Returns [i,j] is the return from a decision made on trading opportunity j for system i.
Algorithm:
nless = 0
for all 'n_combinations' training/testing combinations of subsets
Find the row which has maximum criterion in the training set
Compute the rank (1 through number of test cases) of the test-set criterion in this
row (system) relative to the test criteria for all 'n_systems' rows
Compute fractile = rank / (n_systems + 1)
If fractile <= 0.5
nless = nless + 1
Return nless / n_combinations
注意,我们可以使用一次取n_blocks/2
的n_blocks
个事物的组合数的标准公式来预计算组合数(等式 5-1 )。
(5-1)
在算法的直观描述中,我们将最佳 IS 性能的 OOS 性能与其他 OOS 性能的中值进行了比较。在前面的算法中,我们计算相对等级和相应的分位数,如果分位数小于或等于 0.5,则计算失败。这两种运算是等效的,但是前面算法中显示的方法比计算中值更快。
显而易见,我们希望的是比率nless
/ n_combinations
的一个小值,因为这是我们的最佳表现者在样本外表现不如其竞争对手的大概概率。以这种方式表达使它与普通的 p 值有些相似。
这个测试实际上测量什么?
刚刚描述的测试背后的直觉是有道理的,但重要的微妙之处可能并不明显。我们现在更深入地探讨这一点。
理解这个测试本质的关键点是要认识到它的结果完全是相对于被评估的一组竞争对手而言的。在最常见的(尽管不是强制性的)情况下,这些竞争对手都是相同的模型,但是一个或多个参数的值不同。如果测试真的有用,我们选择试验参数的领域是至关重要的。如果域过宽,包括许多不切实际的参数值,或者如果过于严格,不能覆盖可能的参数值的完整范围,测试就失去了很大的适用性。
当我们说测试的结果是相对于竞争对手的集合,我们的意思是这个测试可以被认为是测量一种优势。它回答了以下问题:当真实世界的性能通过测试中的 OOS 性能来衡量时,就真实世界的性能而言,IS-optimal 模型在多大程度上优于其竞争对手?这里的关键词是竞争对手。
假设我们通过包含大量系统来稀释竞争对手的领域,这些系统是任何一个有理性的开发者都会提前知道是没有价值的。就参数化而言,这相当于测试许多大大超出合理标准的参数集。无论是在样品中还是样品外,这些系统的性能都很差。因此,即使一个稍微不错的系统的 OOS 性能也将高于所有系统的中值性能,从而在这个测试中获得很高的分数,这可能是不应该的。
相反,假设我们将我们的竞争领域限制在只有预先知道可能是好的系统,几乎没有变化。没有一个系统,即使是最好的,会支配其他 OOS,导致一个糟糕的分数。
底线是,我们必须明白,这个测试的分数告诉我们,最好的 is 模型比与之竞争的较差的 is 模型 OOS 表现得更好。因此,我们应该努力确保竞争者彻底但不是不切实际地代表参数域。
CSCV 优势测试的 C++ 代码
在这一节中,我们将介绍 C++ 代码(CSCV _ 核心。CPP)来实现刚刚描述的测试。这段代码将被分成几个部分,每个部分都有自己的解释。我们从函数和局部变量声明开始。该子例程假设来自竞争交易系统的回报已经被计算并存储在矩阵中,如前所述。
double cscvcore (
int ncases , // Number of columns in returns matrix (change fastest)
int n_systems , // Number of rows (competitors)
int n_blocks , // Number of blocks (even!) into which cases will be partitioned
double *returns , // N_systems by ncases matrix of returns, case changing fastest
int *indices , // Work vector n_blocks long
int *lengths , // Work vector n_blocks long
int *flags , // Work vector n_blocks long
double *work , // Work vector ncases long
double *is_crits , // Work vector n_systems long
double *oos_crits // Work vector n_systems long
)
{
int i, ic, isys, ibest, n, ncombo, iradix, istart, nless ;
double best, rel_rank ;
第一步是将返回的ncases
列划分为大小相等或*似相等的n_blocks
个子集。在[Bailey 等人]的论文中,假设ncases
是n_blocks
的整数倍,因此所有子集的大小相同。然而,我认为这并不是严格必要的,而且肯定是限制性的。因此,我使用数组indices
指向每个子集的起始案例,并使用lengths
作为每个子集中的案例数。每个子集中的病例数是剩余病例数除以剩余子集数。
n_blocks = n_blocks / 2 * 2 ; // Make sure it's even
istart = 0 ;
for (i=0 ; i<n_blocks ; i++) { // For all blocks (subsets of returns)
indices[i] = istart ; // Block starts here
lengths[i] = (ncases - istart) / (n_blocks-i) ; // It contains this many cases
istart += lengths[i] ; // Next block
}
我们将 IS-best 系统的 OOS 性能低于其他系统的 OOS 性能的次数的计数器初始化为零。我们还初始化了一个标志数组,它标识哪些子集当前在训练集中,哪些在测试集中。
nless = 0 ; // Will count the number of times OOS of best <= median OOS
for (i=0 ; i<n_blocks / 2 ; i++) // Identify the training set blocks
flags[i] = 1 ;
for ( ; i<n_blocks ; i++) // And the test set blocks
flags[i] = 0 ;
主最外层循环通过所有可能的块组合(返回的子集)进入混合训练集和混合测试集。该循环的第一步是计算每个系统的样本内性能。为此,将被评估系统的所有n
返回收集到单个work
数组中,然后调用外部子程序criter()
来计算性能标准。
for (ncombo=0; ; ncombo++) { // For all possible combinations
/*
Compute training-set (IS) criterion for each candidate system
*/
for (isys=0 ; isys<n_systems ; isys++) { // Each row of returns matrix is a system
n = 0 ; // Counts cases in training set
for (ic=0 ; ic<n_blocks ; ic++) { // For all blocks (subsets)
if (flags[ic]) { // If this block is in the training set
for (i=indices[ic] ; i<indices[ic]+lengths[ic] ; i++) // For every case in this block
work[n++] = returns[isys*ncases+i] ;
}
}
is_crits[isys] = criter ( n , work ) ; // IS performance for this system
}
然后我们对测试集做同样的事情。代码与前面显示的几乎相同,但我们还是会显示它。
for (isys=0 ; isys<n_systems ; isys++) { // Each row of returns matrix is a system
n = 0 ; // Counts cases in OOS set
for (ic=0 ; ic<n_blocks ; ic++) { // For all blocks (subsets)
if (! flags[ic]) { // If this block is in the OOS set
for (i=indices[ic] ; i<indices[ic]+lengths[ic] ; i++) // For every case in this block
work[n++] = returns[isys*ncases+i] ;
}
}
oos_crits[isys] = criter ( n , work ) ; // OOS performance of this system
}
搜索所有系统,找到具有最高样本性能的系统。
for (isys=0 ; isys<n_systems ; isys++) { // Find the best system IS
if (isys == 0 || is_crits[isys] > best) {
best = is_crits[isys] ;
ibest = isys ;
}
}
计算最佳系统的 OOS 性能在所有系统的 OOS 性能总体中的排名。从数学上来说,best >= oos_crits[ibest]
是正确的,但是为了防止浮点歧义,我们对此进行了预先测试。然后,我们计算分位数(rel_rank
),如果这个性能没有超过中值,就增加我们的失败计数器。
best = oos_crits[ibest] ; // This is the OOS value for the best system in-sample
n = 0 ; // Counts to compute rank
for (isys=0 ; isys<n_systems ; isys++) { // Universe in which rank is computed
if (isys == ibest || best >= oos_crits[isys]) // Insurance against fpt error
++n ;
}
rel_rank = (double) n / (n_systems + 1) ;
if (rel_rank <= 0.5) // Is the IS best at or below the OOS median?
++nless ;
我们现在来看这个算法中唯一真正复杂的部分:前进到定义训练集和测试集的下一个块组合。许多读者会愿意相信它的运作。我会在代码后提供一个简短的解释。建议想了解其操作的读者拿出纸笔,算出一连串的组合。测试完所有组合后,我们将失败次数除以总组合次数,得到表现不佳的大概概率。下面是代码:
n = 0 ;
for (iradix=0 ; iradix<n_blocks-1 ; iradix++) {
if (flags[iradix] == 1) {
++n ; // This many flags up to and including this one at iradix
if (flags[iradix+1] == 0) {
flags[iradix] = 0 ;
flags[iradix+1] = 1 ;
for (i=0 ; i<iradix ; i++) { // Must reset everything below this change point
if (--n > 0)
flags[i] = 1 ;
else
flags[i] = 0 ;
} // Filling in below
break ;
} // If next flag is 0
} // If this flag is 1
} // For iradix
if (iradix == n_blocks-1) {
++ncombo ; // Must count this last one
break ;
}
} // Main loop processes all combinations
return (double) nless / ncombo ;
}
这段代码遍历这些块,寻找第一次出现的(1,0)对,并在此过程中计数 1。第一次找到(1,0)对时,它将 1 向右传播,用(0,1)对替换这个(1,0)对。然后,就像算法开始时一样,它将所需数量的 1 移动到数组开头的这一对之前,并用 0 填充前面部分的剩余部分。因此,这些操作不会改变 1 和 0 的计数。这种交换为我们建立了一个全新的、独特的组合家族,因为新的(0,1)对不可能在没有至少一个标志改变的情况下变回(1,0)然后再变回(0,1)。该算法本质上是递归的,最右边的 1 缓慢前进,它下面的所有标志以同样的方式递归变化。
如果你是那种喜欢启发式验证的人,知道你可以通过等式 5-1 从块的数量中显式计算出组合的数量。对推进算法进行编程,并对各种块数进行测试,确认您获得了正确的组合数。你知道不可能有重复,因为如果任何组合再次出现,算法将进入无限循环。因此,如果你得到了正确的组合数,你就知道它们是独一无二的,因此涵盖了所有可能的组合。
SPX 的示例
我们现在来看一个移动*均线与标准普尔 500 指数交叉的例子。我选择这个“市场”是因为它历史悠久,而且异常广阔,从而避免了任何个人股权问题。作为一个兴趣点,我对各种股票和指数重新进行了测试,发现了两个普遍的影响。首先,移动*均交叉系统在过去的几十年里运行得很好,当它们的性能急剧下降时(至少在我的测试中是这样的;我并不是说这是普遍现象)。第二,单个股票有巨大的差异,一些问题对这个系统反应良好,而另一些则不那么好。所以,我在这个例子中的目标是展示 CSCV 优势算法,而不是促进或阻止任何特定交易系统的使用。
我们从一个子程序开始(在 CSCV_MKT。CPP ),它展示了我们如何计算 CSCV 核心公司所需的returns
矩阵。这个例程是用价格历史的数组和用户期望的最大回看来调用的。它计算returns
矩阵。请注意,我们需要向它提供实际价格的日志,这样当市场在 1000 时的移动与当市场在 10 时的移动是相称的。我们将用iret
索引returns
中的项目,?? 在行(条)之间前进最快。
void get_returns (
int nprices , // Number of log prices in 'prices'
double *prices , // Log prices
int max_lookback , // Maximum lookback to use
double *returns // Computed matrix of returns
)
{
int i, j, ishort, ilong, iret ;
double ret, long_mean, long_sum, short_mean, short_sum ;
iret = 0 ; // Will index computed returns
我们有三个嵌套循环。最外层循环将长期回看从最小两个小节变化到用户指定的最大值。下一个循环将短期回顾从最小值 1 变化到比长期回顾小 1,确保短期回顾总是小于长期回顾。最内层的循环遍历价格历史,做出交易决策,计算每笔交易的回报。我们不能在ilong-1
开始这次价格上涨,即使有效的回报数据从那里开始。这是因为returns
矩阵必须是真正的矩阵,每一行都有相同数量的正确对齐的列。因此,对于每个系统,我们需要从同一个起点开始。
for (ilong=2 ; ilong<=max_lookback ; ilong++) { // Long-term lookback
for (ishort=1 ; ishort<ilong ; ishort++) { // Short-term lookback
for (i=max_lookback-1 ; i<nprices-1 ; i++) { // Compute returns across history
我们可以明确地计算每根棒线的移动*均线,但这会非常慢。一种更快的方法是,在第一根棒线上计算两个移动和一次,并从那时起更新它们,尽管由于浮点误差的积累,这种方法的精确度会稍差一些。对于每根棒线,除以移动总和得到移动*均线。
if (i == max_lookback-1) { // Find the moving averages for the first valid case.
short_sum = 0.0 ; // Cumulates short-term lookback sum
for (j=i ; j>i-ishort ; j--)
short_sum += prices[j] ;
long_sum = short_sum ; // Cumulates long-term lookback sum
while (j>i-ilong)
long_sum += prices[j--] ;
}
else { // Update the moving averages
short_sum += prices[i] - prices[i-ishort] ;
long_sum += prices[i] - prices[i-ilong] ;
}
short_mean = short_sum / ishort ; // Convert sums to averages
long_mean = long_sum / ilong ;
交易规则是,如果短期均线在长期均线之上,我们就做多,反之亦然。如果两条均线相等,我们保持中性。我在我的assert()
中向读者阐明了returns
矩阵中现在有多少项。
// We now have the short-term and long-term moving averages ending at bar i
if (short_mean > long_mean) // Long position
ret = prices[i+1] - prices[i] ;
else if (short_mean < long_mean) // Short position
ret = prices[i] - prices[i+1] ;
else // Be neutral
ret = 0.0 ;
returns[iret++] = ret ; // Save this return
} // For i (decision bar)
} // For ishort, all short-term lookbacks
} // For ilong, all long-term lookbacks
assert ( iret == (max_lookback * (max_lookback-1) / 2 * (nprices - max_lookback)) ) ;
}
当我在 SPX 上运行这个程序时,我尝试了几种不同的块数和最大回看数。获得了以下结果,提供了重要的证据,移动*均线交叉系统在这个市场中提供了有用的预测信息。
Blocks Max lookback Probability
10 50 0.008
10 100 0.016
10 150 0.036
12 50 0.004
12 100 0.009
12 150 0.027
这没有告诉我们任何关于风险/回报比率的信息,所以这个系统可能不值得交易。但它确实表明,经过优化训练的模型在样本外大大优于次优的竞争对手。这是有价值的信息,因为它告诉我们这个模型有真正的潜力;如果模型有缺陷,训练将增加很少或没有价值(OOS 表现),概率将接* 0.5。
嵌套向前分析
有时,我们的开发过程要求我们将一层前向分析嵌套在另一层中。这种情况的经典例子是投资组合构建。我们有一个包含在投资组合中的候选集合,其中的每一个都需要某种程度的性能优化(可能是单独的,也可能是具有公共参数的组)。我们也有一些投资组合表现的标准,我们用来选择这些候选人的子集,以纳入交易组合。无论是哪种情况,优化的两个阶段正在发生(投资组合的组成部分和投资组合作为一个整体),因此,为了估计投资组合的真实表现,我们必须执行嵌套的 walkforward 分析。下面是几个例子(远不完整!)在必要时:
-
我们有各种各样的交易系统,它们的表现依赖于缓慢变化的市场机制。例如,我们可能有一个趋势跟踪系统,一个均值回复系统和一个通道突破系统。我们跟踪这三个系统中哪一个最*表现最好,当我们做交易决定时,我们使用当前最好的系统。
-
我们有一个适用于几乎所有股票的交易系统,但是我们从经验中知道,不同的股票家族(交通、金融、消费品等等)在不同的时间用这个系统有更好的表现。我们跟踪哪些股票最*对我们的交易系统反应最好,这些就是我们交易的股票。
-
你的一位同事坚持认为,*均回报率是衡量市场或交易系统表现的最佳指标。另一个主张夏普比率,而另一个喜欢利润因素。凭你的智慧,你怀疑理想的衡量标准可能会随着时间而改变。所以,你不用运行三个单独的测试,比较从头到尾的表现,而是跟踪哪一个表现指标是当前最准确的,并用这个指标来选择你当前交易的系统或市场。
为什么在这种情况下我们需要使用嵌套的 walkforward?为什么我们不能优化整个过程,将单个组件的参数化和组性能合并到一个大的可优化参数中呢?答案是,这些操作的第二阶段,无论是选择单个系统或投资组合组件,还是第二轮集中优化,都必须基于第一阶段的 OOS 结果。
让我们考虑一个简单的例子,在这个例子中,我们避免了不断变化的市场条件的复杂性。选择偏差的主题在 124 页已经介绍过了,现在可能是回顾这一节的好时机。你的部门成员给了你,部门主管,他们开发的各种模型,并提议公司交易。你必须从这些模型中选择最好的。你会检查竞争者的样品性能并选择最好的吗?当然不是,而且有充分的理由:如果这个系统过于强大(通常是因为它有太多可优化的参数),它会过度拟合市场历史,除了真实的模式之外还会模拟噪音。当这个系统在现实世界的交易中投入使用时,噪音模式将会消失(这就是噪音的定义),你将会得到一堆垃圾。明智的做法是比较竞争系统的 OOS 性能,并以此作为选择的依据。
当你处理一个不断变化的情况时,情况不会改变。你仍然需要根据竞争对手的 OOS 表现,定期、反复地决定交易什么或投资组合中包括什么市场。这是因为样本内的表现很少告诉我们交易系统在现实世界中的表现。
这就是为什么我们需要嵌套的 walkforward。我们需要一个 walkforward 的内部级别(我喜欢称之为 Level-1 )来提供 OOS 结果,而 Level-2 优化将基于这些结果。当然,二级交易决策本身也需要通过 OOS 检验和 walkforward 分析。因此,我们嵌套了两个层次的向前遍历分析。
为了准备和阐明即将出现的算法,我们给出一个小例子来说明这个过程是如何工作的。对于这个例子,我们假设 1 级训练(通常是优化单个交易系统)的回顾是 10 根棒线,2 级优化(通常是从竞争的交易系统中选择)的回顾是 3 根棒线。然后我们进行如下操作:
Use Price Bars 1-10 to train the individual competitors.
Test each competitor with Bar 11, giving our first Level-1 OOS case
Use Price Bars 2-11 to train the individual competitors.
Test each competitor with Bar 12, giving our second Level-1 OOS case
Use Price Bars 3-12 to train the individual competitors.
Test each competitor with Bar 13, giving our third Level-1 OOS case
We now have enough Level-1 OOS cases to commence Level-2 testing
Use Level-1 OOS Bars 11-13 to train the Level-2 procedure
Test the Level-2 procedure on Bar 14, giving our first totally OOS case
Use Price Bars 4-13 to train the individual competitors.
Test each competitor with Bar 14, giving a new Level-1 OOS case
Use Level-1 OOS Bars 12-14 to train the Level-2 procedure
Test the Level-2 procedure on Bar 15, giving our second totally OOS case
Repeat the prior four steps, advancing the price and Level-1 OOS windows, until the historical data is exhausted
嵌套的 Walkforward 算法
有经验的程序员应该能够在给出先前的解释和例子的情况下编写嵌套的 walkforward。但是为了清楚起见,我将以一种相当一般的方式来陈述这个算法。这是嵌套式向前交易的最常见用法:你有两个或更多的交易系统,在每根棒线上,查看最*的市场历史,并决定在下一根棒线上的头寸(多头/空头/中性)。你也有一个评分系统,检查每个系统最*的 OOS 表现,选择一个明显更好的交易系统子集(也许只有一个)用于下一次交易。你的目标是从这个最佳子集收集 OOS 交易。这可以让你评估你的整个交易系统的表现,包括基础系统以及评分和选择最好的方法。以下变量尤其重要:
-
n_cases
:价格数组中市场价格历史棒线的数量。 -
prices
:市场历史(价格记录)。我们称这里的单位为棒线,但是这个信息也可以包括其他的指标,比如成交量和未*仓合约。 -
n_competitors
:相互竞争的交易系统数量。 -
IS_n
:用户指定的交易系统回看;用于交易决策的*期市场历史棒线数量。 -
OOS1_n
:用户指定的系统选择器回看;多个交易系统产生的最* OOS 回报的数量,由系统选择器用来选择最佳系统。 -
OOS1
:交易系统的 OOS 收益,一个由n_cases
矩阵构成的n_competitors
。注意,这个矩阵中的第一个IS_n
列没有被使用,因为它们没有被定义。该矩阵的列j
包含棒线j
产生的回报,作为对棒线j–1
做出的决策的结果。 -
OOS2
: OOS 返回所选的最佳系统;我们的最终目标。 -
IS_start
:训练集的开始栏。它随着窗口前进。 -
OOS1_start
:系统选择器使用的当前系统 OOS 集合的起始栏 OOS1 中的索引。一旦系统选择器有OOS1_n
个病例要回顾,它就随着窗口前进。 -
OOS1_end
:系统选择器使用的当前系统 OOS 集合的倒数第一条。它随着窗口前进。这也作为当前 OOS1 案例索引。当算法开始时,它等于OOS1_start
,并且每当窗口前进时它就递增。 -
OOS2_start
:完整 OOS 集 2 的起始索引;它保持固定在IS_n + OOS1_n
。 -
OOS2 中的最后一个案例。这也是当前 OOS2 案例索引。
下一节中显示的算法经过大量编辑,可广泛应用。在下一节中,我们将展示一个完整的 C++ 程序,它在一个略有不同但相当的应用程序中使用嵌套的 walkforward。这里,我们首先将系统价格历史中的起始指数初始化为历史上的第一个案例。系统 OOS 回报在系统回望期后立即开始。选择者的 OOS 回归,我们的最终目标,紧接在 OOS 时期之后开始。然后,我们开始在价格历史系列中移动窗口的主循环。
IS_start = 0 ; // Start training with first case
OOS1_start = OOS1_end = IS_n ; // First OOS1 case is right after first price set
OOS2_start = OOS2_end = IS_n + OOS1_n ;// First OOS2 case is after OOS1 complete
for (;;) { // Main outermost loop advances windows
每个窗口位置的第一步是评估该棒线处的所有竞争对手(交易系统),并将结果存储在OOS1
中,这是一个二维数组,系统位于行下方,棒线位于列上方,该指数变化最快。例程criterion_1()
处理所有系统,所以我们必须告诉它我们要评估哪个系统。为了评估一个系统,它会查看以小节IS_start
开始、以小节IS_start+IS_n-1
结束的IS_n
小节。请注意,它不会查看条形OOS1_end
,条形将始终是该采样周期后的下一个条形。
在绝大多数应用中,criterion_1()
将使用市场历史的IS_n
棒线来寻找模型参数,使这些IS_n
棒线内的交易系统的性能最大化。然后,它将决定下一个杆的位置,该位置在杆OOS1_end=IS_start+IS_n
处。作为大多数应用程序的最后一步,criterion_1()
将在这个条OOS1_end
上返回该交易产生的利润/损失。如果优化后的模型说要做多,这个回报就是prices[OOS1_end] – prices[OOS1_end–1]
。(回想一下,价格几乎总是实际价格的对数。)如果模型要求建立空头头寸,criterion_1()
将返回差额的负值,当然,如果头寸是中性的,则返回将为零。我没有在这里显示的算法中明确包括这种典型的行为,而是让它通用,以允许更复杂的交易系统,这些系统可能会在某些交易中翻倍,等等。
for (icompetitor=0 ; icompetitor<n_competitors ; icompetitor++)
OOS1[icompetitor*n_cases+OOS1_end] =
c riterion_1 ( icompetitor , IS_n , IS_start , prices ) ;
在上一步中,我们计算了最后一个历史棒线处的 OOS1 值,此时我们已经完成了移动窗口价格历史的遍历。在这一点上,没有更多的事情要做,因为没有另一个棒线用于计算 OOS2,即所选最佳系统的性能。
if (OOS1_end >= n_cases-1) // Have we hit the end of the data?
break ; // Stop due to lack of another for OOS2
我们现在负责推进移动窗口的部分任务。在算法开始时有一个预热阶段,我们建立了足够多的 OOS1 案例,以允许选择器函数做出决定。不管我们是否有足够多的 OOS1 案例,我们都会增加训练成分交易系统的起始价格指数,我们也会增加放置下一个 OOS 回报的 OOS1 指数。但是如果到目前为止计算出的 OOS1 条的数量OOS1_end – OOS1_start
还没有达到选择器所要求的数量OOS1_n
,那么我们还没有做更多的事情,我们只是继续推进窗口。
++IS_start ; // Advance training window start
++OOS1_end ; // Advance current OOS1 case
if (OOS1_end - OOS1_start < OOS1_n) // Are we still filling OOS1?
continue ; // Can't proceed until we have enough cases to compute an OOS2 return
当我们到达这里时,OOS1 中有足够的事例来调用系统选择器并计算 OOS2 事例。首先我们找到最好的交易系统,使用每个系统最*的OOS1
中的OOS1_n
值。记住OOS1_end
现在比OOS1
多了一位(几行前我们增加了一位)。因此,OOS1_end
酒吧的价格是小样。
这里的选择器功能是criterion_2()
。它的第一个参数是要检查的OOS1
值的数量,第二个参数是值向量的起始地址。如有必要,回头看看这些值在OOS1
中是如何排列成矩阵的。
在这个算法中,我们找到单一的最佳交易系统,并评估其回报。相反,希望找到系统组合的读者应该很容易修改这个演示文稿。只需为每个系统调用criterion_2()
,将值保存在一个数组中,并对数组进行排序。你想要多少最好的就保留多少。
best_crit = -1.e60 ;
for (icompetitor=0 ; icompetitor<n_competitors ; icompetitor++) { // Find the best
crit= criterion_2(OOS1_end-OOS1_start, OOS1+icompetitor*n_cases+OOS1_start);
if (crit > best_crit) {
best_crit = crit ;
ibest = icompetitor ;
}
}
我们现在知道了最好的竞争对手,所以找到它的 OOS 回报。这里的函数trade_decision()
使用优化的交易系统ibest
来决定持仓。当我讨论criterion_1()
的时候,我指出我允许不同规模的头寸。我没有让这个版本通用,只是因为我想完全清楚如何计算交易决策的回报。如果您的系统可能会因不同的信任度而开立多个头寸,您必须适当修改此代码。这个例程在禁止OOS2_end
做出决定之前检查最*的IS_n
价格。请注意,Bar OOS2_end
不包含在决策过程中,因此它是样本外的。
position = trade_decision ( ibest , IS_n , OOS2_end - IS_n , prices ) ;
if (position > 0) // Long
OOS2[OOS2_end] = prices[OOS2_end] - prices[OOS2_end-1] ;
else if (position < 0) // Short
OOS2[OOS2_end] = prices[OOS2_end-1] - prices[OOS2_end] ;
else // Neutral
OOS2[OOS2_end] = 0.0 ;
我们可以完成推进移动窗口的过程。在OOS1
包含足够的值之前(选择器criterion_2()
需要OOS1_n
),我们没有推进OOS1_start
。但是我们现在提前了,因为OOS1
窗口已经满了。当然我们会提前OOS2_end
。
++OOS1_start ; // Finish advancing the windows
++OOS2_end ;
} // Main loop
我们已经走过了整个市场历史。此时,OOS1_end
和OOS2_end
都等于n_cases
,因为它们总是指向一个超过最后一个条目的值,我们处理了每一个可能的条。
现在,整个市场历史已经处理完毕,我们可以计算一些可能感兴趣的东西。首先,我们计算并保存每个系统的*均 OOS 性能。每个条形的信息在OOS1
中。我们可以包含OOS1
中的每个条目,一些开发人员可能会对这个数字感兴趣。然而,出于我们的目的,我们希望有一个公*的竞争环境,所以我们只包括那些在OOS2
也可用的酒吧,它比OOS1
晚开始。在这个演示中,我们计算的性能指标只是每根棒线的*均回报,但我们也可以计算利润因子、夏普比率或任何其他指标。毕竟,OOS1
每一行的累计总和只是一条棒线对棒线的权益曲线,我们可以用任何方式对其进行评估。
for (i=0 ; i<n_competitors ; i++) {
sum = 0.0 ;
for (j=OOS2_start ; j<OOS2_end ; j++)
sum += OOS1[i*n_cases+j] ;
crit_perf[i] = sum / (OOS2_end - OOS2_start) ;
}
最后一步是计算我们的最终目标,即所选最佳系统的 OOS 性能。这些回报在OOS2
中。与 OOS1 一样,我们在这里计算均值回报,但也可以随意计算其他指标。
sum = 0.0 ;
for (i=OOS2_start ; i<OOS2_end ; i++)
sum += OOS2[i] ;
final_perf = sum / (OOS2_end - OOS2_start) ;
嵌套向前行走的一个实际应用
在上一节中,我们看到了嵌套 walkforward 的最常见用法的概要,以一系列 C++ 代码片段的形式呈现。现在我们介绍这种技术的一种不同的用法,这一次是以一个完整的程序的形式,用户可以根据需要修改、编译并在实际应用中使用。这个程序可以作为选择器下载。CPP 和是完整的,准备编译和运行。
这一应用背后的动机是,在一个股票宇宙中,市场轮流成为表现最好的市场。在某些时期,银行可能表现出色,而在其他时期,技术可能占据主导地位。总的想法是,每天(或者其他时间段,如果我们想的话)我们检查宇宙中的每个股票,并选择最*表现最好的一个。我们在第二天买入并持有这支目前处于优势的股票,然后重新评估形势。
这个嵌套的向前遍历演示将回看窗口逐栏移动。打印结果的缩放假设这些是日棒线,但是当然在高速情况下它们可以是分钟棒线,在更放松的环境下是周棒线,或者是开发人员想要的任何东西。
在每根柱线上,它分析了多个市场最*的长期表现。它收集了单个市场的表现,这些表现是通过在历史窗口期简单地买入并持有该市场而获得的。然后,它买入并持有最*表现最好的下一根棒线。但是我们如何衡量每个竞争市场的表现来选择最佳市场呢?我们使用每根棒线的*均回报率吗?夏普比率?利润因素?这是这个应用程序的选择方面。在每一根棒线上,我们尝试几种不同的业绩指标,看看哪种指标在一个单独的历史窗口内提供了最好的 OOS 回报。当我们买下一根棒线的最佳市场时,我们的决定是基于最* OOS 记录最好的业绩指标。因此,我们需要一个第二级选择的 OOS 绩效数字,其中我们使用一个“最佳衡量”来选择一个“最佳市场”需要嵌套的 walkforward。
要使用命令行选择器程序,用户提供一个市场历史文件列表,每个文件的文件名指定市场的名称。例如,IBM.TXT
包含 IBM 的市场历史价格。市场历史文件的每一行都有日期(YYYYMMDD)、开盘价、最高价、最低价和收盘价。线路上的任何附加数字(如音量)都将被忽略。例如,市场历史文件中的一行可能如下所示:
20170622 1075.48 1077.02 1073.44 1073.88
除了提供列出市场文件的文本文件的名称之外,用户还指定IS_n
,市场价格历史的回顾,用于找到当前表现最好的市场;OOS1_n
,市场级 OOS 的回望结果为选择当前表现最好的标准;以及蒙特卡洛复制的次数(稍后讨论)。例如,用户可以从命令行调用选择器程序,如下所示:
CHOOSER Markets.txt 1000 100 100
Markets.txt
文件可能如下所示:
\Markets\IBM.TXT
\Markets\OEX.TXT
\Markets\T.TXT
etc.
前面的命令行还说,将检查最*市场历史的 1,000 根棒线以找到最佳市场,该市场选择过程的 100 根棒线的 OOS 业绩将用于选择最佳业绩标准。它还说将进行 100 次蒙特卡洛复制来测试结果的统计显著性。这个题目将在 283 页介绍。
在这里,我们将介绍选择器的嵌套前进部分。CPP 代码比我们以前使用的通用算法更详细。但是请注意,完整的程序包括一个蒙特卡罗置换检验,我们在第 316 页之前不会讨论它,所以为了避免混淆,现在将省略这些代码部分。
为了明确起见,这里有三个不同的性能标准,它们将用于决定众多市场中哪一个是目前最有前景的。它们有两个参数:要检查的(对数)价格的数量和一个指向价格数组的指针。价格数组实际上必须是真实价格的对数,以使它们与规模无关,并享有简介中讨论的其他属性。
一个细分市场的总回报就是它的最后价格减去它的第一个价格。为了计算原始(非标准化)夏普比率,我们首先计算每根棒线的*均回报率,然后计算棒线与棒线变化的方差。原始夏普比率是*均值除以标准差。利润因子是所有向上移动的总和除以所有向下移动的总和。最后,criterion()
调用指定的例程。
double total_return ( int n , double *pric es )
{
return prices[n-1] - prices[0] ;
}
double sharpe_ratio ( int n , double *prices )
{
int i ;
double diff, mean, var ;
mean = (prices[n-1] - prices[0]) / (n - 1.0) ;
var = 1.e-60 ; // Ensure no division by 0 later
for (i=1 ; i<n ; i++) {
diff = (prices[i] - prices[i-1]) - mean ;
var += diff * diff ;
}
return mean / sqrt ( var / (n-1) ) ;
}
double profit_factor ( int n , double *prices )
{
int i ;
double ret, win_sum, lose_sum ;
win_sum = lose_sum = 1.e-60 ;
for (i=1 ; i<n ; i++) {
ret = prices[i] - prices[i-1] ;
if (ret > 0.0)
win_sum += ret ;
else
lose_sum -= ret ;
}
return win_sum / lose_sum ;
}
double criterion ( int which , int n , double *prices )
{
if (which == 0)
return total_return ( n , prices ) ;
if (which == 1)
return sharpe_ratio ( n , prices ) ;
if (which == 2)
return profit_factor ( n , prices ) ;
return -1.e60 ; // Never get here if called correctly
}
读取市场历史的代码很简单,但是很乏味,所以在讨论中不再赘述。此外,所有市场的棒线必须在时间上对齐,因此,如果任何市场缺少某个棒线的数据,该棒线必须从所有其他市场中删除,以保持时间对齐。这在主要市场中是罕见的事件。这段代码也很乏味,因此在讨论中省略了;请参见选择器。CPP 的这个代码,高度评价。这里我们关注嵌套的 walkforward 代码,它使用了以下变量:
-
n_cases
:市场价格历史棒线的数量。 -
market_close[][]
:市场历史(价格记录)。第一个指数是市场,第二个是酒吧。 -
n_markets
:市场数量(在market_close
中的行数)。 -
IS_n
:用户指定的每个选择标准要检查的最*市场历史棒线的数量。 -
OOS1_n
:用户指定的市场选择器回看;最*从市场获得的 OOS 收益数,用于选择最佳的市场选择方法。 -
n_criteria
:竞争市场选择标准的数量。 -
OOS1
:由每个竞争标准确定的“最佳”市场的 OOS 回报,一个由n_cases
矩阵确定的n_criteria
。该矩阵的列j
包含棒线j
作为棒线j–1
的“最佳市场”决策的结果而产生的回报。 -
OOS2
:用最佳标准选择的市场的 OOS 回报率。 -
IS_start
:当前市场表现窗口的开始栏。 -
OOS1_start
:当前窗口开始栏 OOS1 中的索引。一旦系统选择器有OOS1_n
个病例要回顾,它就随着窗口前进。 -
OOS1_end
:当前 OOS1 窗口的最后一个条。它随着窗口前进。这也作为当前 OOS1 案例索引。 -
OOS2_start
:完整 OOS 集 2 的起始索引;它保持固定在IS_n + OOS1_n
。 -
OOS2_end
:OOS 2 中最后一个案例过去一个。这也是当前 OOS2 案例索引。
用户会发现将通过本节的市场选择程序获得的业绩与通过购买和持有单个市场或一篮子竞争市场获得的业绩进行比较是很有趣的。所以,我们打印这些信息。为了便于公*比较,我们应该考虑将参与OOS2
计算的完全相同的棒线。OOS2
中的第一根棒线将位于IS_n + OOS1_n
,其回报与前一根棒线的价格相关。OOS2
中的最后一个杆将位于n_cases–1
,因为杆索引为零原点。我们将每根棒线的*均回报率乘以 25200。当价格是日线时,这是合理的,因为一年通常有 252 个交易日。这些价格实际上是对数价格,接*相对于先前价格的部分回报。因此,打印出来的值接*年化百分比回报率。下面是这段代码:
fprintf ( fpReport, "\n\n25200 * mean return of each market in OOS2 period..." ) ;
sum = 0.0 ;
for (i=0 ; i<n_markets ; i++) {
ret = 25200 * (market_close[i][n_cases-1] - market_close[i][IS_n+OOS1_n-1]) /
(n_cases - IS_n - OOS1_n) ;
sum += ret ;
fprintf ( fpReport, "\n%15s %9.4lf", &market_names[i*MAX_NAME_LENGTH], ret ) ;
}
fprintf ( fpReport, "\nMean = %9.4lf", sum / n_markets ) ;
做一些初始化。用户可能有兴趣知道每个市场选择标准根据其 OOS 性能被选为最佳的次数,因此我们将一组计数器置零。我们还初始化各种指数,让我们遍历市场历史。
for (i=0 ; i<n_criteria ; i++)
crit_count[i] = 0 ; // Counts how many times each criterion is chosen
IS_start = 0 ; // Start market window with first case
OOS1_start = OOS1_end = IS_n ; // First OOS1 case is right after first price set
OOS2_start = OOS2_end = IS_n + OOS1_n ; // First OOS2 case after complete OOS1
贯穿市场历史的主要循环是下一个。每次循环(窗口放置)的第一步是评估每个市场的最*历史表现,用每个竞争标准来衡量。对于每个标准,找到最*表现最好的市场,希望这个市场的出色表现会持续下去,至少到下一个酒吧。我们通过从当前棒线到下一个棒线(棒线OOS1_end
)的变化来衡量下一个棒线的性能。我们在 OOS1 中保存这个 OOS 性能。
for (;;) { // Main loop marches across market history
for (icrit=0 ; icrit<n_criteria ; icrit++) { // For each competing performance criterion
best_crit = -1.e60 ;
for (imarket=0 ; imarket<n_markets ; imarket++) {
crit = criterion ( icrit , IS_n , market_close[imarket]+IS_start ) ;
if (crit > best_crit) {
best_crit = crit ;
ibest = imarket ; // Keep track of which market is best according to this criterion
}
}
OOS1[icrit*n_cases+OOS1_end] =
market_close[ibest][OOS1_end] - market_close[ibest][OOS1_end-1] ;
}
在前面所示的icrit
循环结束时,我们在 OOS1 中获得了每个标准认为最有前景的市场的下一棒(OOS)表现。如果我们已经到达了市场数据的末尾,我们现在就脱离历史遍历循环。否则,使那些总是前进的窗口指针前进。然后查看我们在OOS1
中是否有足够的条(OOS1_n
)来选择最佳标准。
if (OOS1_end >= n_cases-1) // Have we hit the end of the data?
break ; // Stop due to lack of another for OOS2
++IS_start ; // Advance training window
++OOS1_end ; // Advance current OOS1 case
if (OOS1_end - OOS1_start < OOS1_n) // Are we still filling OOS1?
continue ; // Cannot proceed until enough cases to compute an OOS2 return
当我们达到这一点时,我们在 OOS1 中有足够的棒线来比较竞争标准,看看哪一个在选择市场方面做得最好,其出色的表现将延续到下一个棒线。在这里,我们对标准能力的衡量只是回顾窗口中每个竞争标准的总 OOS 回报。纯粹为了用户的熏陶,统计一下每个标准有多少次被选为最靠谱的。
for (icrit=0 ; icrit<n_criteria ; icrit++) { // Find the best criterion using OOS1
crit = 0.0 ; // Measures competence of icrit
for (i=OOS1_start ; i<OOS1_end ; i++) // Lookback window for competence
crit += OOS1[icrit*n_cases+i] ; // Total return is a decent measure
if (crit > best_crit) {
best_crit = crit ;
ibestcrit = icrit ; // Keep track of most reliable criterion
}
}
++crit_count[ibestcrit] ; // This is purely for user's edification
在刚刚展示的循环结束时,我们知道ibestcrit
是标准,至少在最*,被证明是选择最佳市场购买的最可靠的方法。所以我们用这个标准来评估每个市场的*期表现,选择最佳市场买入。我们在棒线OOS2_end
之前检查IS_n
价格,棒线【】将是这个二级 OOS 棒线。
best_crit = -1.e60 ;
for (imarket=0 ; imarket<n_markets ; imarket++) { // Use best crit to select market
crit = criterion ( ibestcrit , IS_n , market_close[imarket]+OOS2_end-IS_n ) ;
if (crit > best_crit) {
best_crit = crit ;
ibest = imarket ; // Keep track of best market as selected by best criterion
}
}
我们现在知道哪个市场被选为最*表现最好的市场,我们是根据最*表现最可靠的标准进行选择的。所以希望这是一个伟大的选择;这是用最可靠的标准选出的最佳市场。我们通过计算从被检查的OOS1
中的最后一根棒线到下一根棒线OOS2_end
的价格变化来测试这一点。将此回报保存在OOS2
。最后,推进我们之前没有推进的窗口索引。
OOS2[OOS2_end] =
market_close[ibest][OOS2_end] - market_close[ibest][OOS2_end-1] ;
++OOS1_start ; // Finish advancing window across market history
++OOS2_end ;
} // Main loop that traverses market history
艰难的工作完成了。我们在OOS2
中看到了 OOS 从我们的双重选择过程中获得的回报,使用了目前最好的标准来选择目前最有前景的市场。现在是计算和打印汇总结果的时候了。可以参考 CHOOSER。CPP 来看看我如何打印这些结果,如果你想;他们的计算显示在这里。回想一下,正如我们在本演示开始时对原始市场所做的那样,性能只考虑那些可用于OOS2
的棒线。这使得所有性能数据都具有可比性。此外,正如我们对原始市场回报所做的那样,我们乘以 25,200,使这些数字成为日棒线的*似年化百分比回报。
for (i=0 ; i<n_criteria ; i++) { // Provide separate results for each criterion
sum = 0.0 ;
for (j=OOS2_start ; j<OOS2_end ; j++)
sum += OOS1[i*n_cases+j] ;
crit_perf[i] = 25200 * sum / (OOS2_end - OOS2_start) ;
}
sum = 0.0 ;
for (i=OOS2_start ; i<OOS2_end ; i++)
sum += OOS2[i] ;
final_perf = 25200 * sum / (OOS2_end - OOS2_start) ;
使用 S&P 100 组件的示例
我在 S&P 100 的大部分组件上运行了刚才描述的选择器程序,这些组件的历史至少可以追溯到 1986 年末。这提供了 65 个市场超过 20 年(7725 天)的数据。市场回看(每个业绩标准检查的价格数量)是 1000 根棒线(天),OOS1 回看(用来比较业绩标准的最好市场 OOS 棒线的数量)是 100。进行了 1000 次重复的蒙特卡罗置换检验。有关这些 p 值的讨论,请参见第 316 页。获得的结果如下:
Mean = 8.7473
25200 * mean return of each criterion, p-value, and percent of times chosen...
Total return 17.8898 p=0.076 Chosen 67.8 pct
Sharpe ratio 12.9834 p=0.138 Chosen 21.1 pct
Profit factor 12.2799 p=0.180 Chosen 11.1 pct
25200 * mean return of final system = 19.1151 p=0.027
这告诉我们关于该测试的以下事情:
-
如果我们在 OOS2 期间简单地购买并持有所有这些股票,我们将获得大约 8.7473%的年回报率。
-
如果我们只使用总回报来选择目前表现最好的市场,我们将获得大约 17.8898 的年回报。
-
仅使用夏普比率或仅使用利润因子将分别提供 12.9834%和 12.2799%的较低回报。
-
当我们将所有三个标准投入竞争时,它们分别被选为最可靠的 67.8%、21.1%和 11.1%。
-
如果我们还跟踪哪个标准是目前最可靠的,我们的 OOS 年回报率大约增加到 19.1151%。
Walkforward 中嵌套的交叉验证
通常情况下,我们希望将交叉验证嵌套在 walkforward 分析中。为了理解什么时候这是合适的,回想一下测试自动交易系统中交叉验证和 walkforward 分析之间的基本权衡:交叉验证比 walkforward 测试更有效地利用可用数据,但它不能反映现实生活。它可能遭受悲观或乐观的偏见,并且它的结果通常与从通常更“合理”的向前行走分析中获得的结果大不相同。
这种权衡使我们倾向于交叉验证,而不是当它的弱点不是非常重要的问题时进行测试。在前两节介绍的嵌套前推示例中,偏差和“实际应用”不仅是最终结果中的重要考虑因素,也是OOS1
内部结果中的重要考虑因素,因为内部结果使我们能够从竞争的性能评估函数中进行选择。但是有些情况下,缺乏现实生活中的一致性,包括小的偏见问题,并不那么严重。
两个典型的情况是模型复杂性的优化和预测变量的选择。显然,这两者都适用于模型驱动的交易系统,而不是基于规则的算法系统。然而,在一些(罕见的)情况下,在算法系统的前向测试中嵌入交叉验证可能是有用的。
不可否认,将交叉验证还是前向遍历嵌入到外部前向遍历分析中的决定通常是不明确的,也是有争议的。然而,作为一个例子,考虑在预测市场运动的多层前馈网络中优化隐藏神经元的数量。如果我们的神经元太少,模型就会太弱,无法找到预测模式。如果我们有太多,模型会过度拟合数据,除了真实的模式之外还会学习随机噪声。我们需要甜蜜点。
这个最佳点从根本上取决于数据中噪声的性质和程度,因此我们希望在做出这个复杂性决策时使用尽可能多的数据,从而有利于交叉验证。此外,我们并不太关心优化过程是否反映了现实生活中的进度;我们只是在寻找由数据性质决定的模型的理想结构。此外,预期由于使用少于完整数据集(第 150 页)而产生的任何悲观偏差将在所有复杂性试验中大致相等地反映出来,并且由于非*稳性泄漏(第 150 页)而产生的任何乐观偏差也将相当*衡,这并不是不合理的。我们在这个测试中的唯一目标是评估由于过度拟合导致的乐观偏差,这在比较不同复杂性的模型时会很突出。所以在这种情况下,我们倾向于交叉验证。
为了清楚地了解在 walkforward 分析中嵌入交叉验证的过程,考虑下面这个小例子。我们想决定是否应该在我们的神经网络中使用三个或五个隐藏神经元。我们将历史数据集分成 10 个部分(1-10 ),并选择使用三重交叉验证。因此,我们采取以下措施:
-
将模型配置为具有三个隐藏的神经元。
-
用章节 2 和 3 训练模型,预测章节 1 中的案例。
-
用章节 1 和 3 训练模型,预测章节 2 中的案例。
-
用章节 1 和 2 训练模型,预测章节 3 中的案例。
-
汇总第 1–3 节的预测,并计算该三神经元模型的 OOS 性能。
-
将模型配置为具有五个隐藏神经元。
-
重复步骤 2-5 以获得五个神经元的性能。
-
选择哪个模型(三个或五个隐藏神经元)具有更好的 OOS 性能。用 1—3 段训练模型。
-
使用这个模型来预测第四部分第一部分,我们的第一个终极 OOS 集。
-
如果我们还没有到达第十部分(最后一部分),重复步骤 1-9,除了每个部分的编号增加到下一个,将整个操作窗口向前移动一个部分。
-
当我们到达终点时,我们有了第 4–10 段的步行前进 OOS 数据。汇集它得到一个宏伟的业绩数字。如果不满意,就从头开始。
-
如果我们对大的性能感到满意,对整个数据集(任何合理的折叠数)使用交叉验证两次,计算三个和五个神经元模型的 OOS 性能。
-
选择表现更好的模型,用最*的三个部分(为了与我们的测试保持一致)或整个数据集(为了最大限度地使用数据)来训练它,以便在交易中使用。
最后一步值得讨论一下。当训练最终模型用于生产时,我们应该使用多少数据?在本例的步行测试中,我们用三个数据块训练每个模型进行 OOS 测试。为了保持一致,我们的生产模型也应该用最*的三个块进行训练。如果我们担心市场存在显著的不稳定性,这是一件好事。但是通过使用所有可用的数据,我们创建了一个更稳定的模型。这两种选择都是合理的。
在前面几节中,我们介绍了一个通用算法和一个如何在 walkforward 中嵌套 walkforward 的具体示例。这个过程涉及到一些相当复杂的操作,包括最低级别市场数据、中级 OOS 结果和高级 OOS 结果的起始和终止指数。在大多数应用程序中,这是解决问题的最简单和最清晰的方法,尽管有一定的复杂性。
但是当嵌入交叉验证时,事情变得更加复杂。出于这个原因,也因为在大多数应用中,交叉验证是模型训练过程的一部分,我们几乎总是采取不同的、更简单的方法。前页所示示例的步骤 1-8 通常在单个子例程调用中执行,而不是像嵌入式 walkforward 那样混合在整个过程中执行。
换句话说,我们有一个单独的子例程(可能调用其他例程)来处理各个折叠的训练,监督模型架构之间的交叉验证竞争,并训练最终的模型。然后在简单的 walkforward 实现中调用这个子例程;它是用最早的市场历史数据块调用的,然后使用训练好的模型对一个或多个市场数据条进行交易,无论用户希望测试窗口有多长,测试集都是如此。这些 OOS 结果被保留,并且整个训练/测试窗口被向前移动,使得下一个测试窗口中的第一个条形紧跟在当前测试窗口中的最后一个条形之后。该窗口向前移动,直到到达数据的末尾。结果是,就前向行走分析而言,它只是预测模型的原始单层前向行走,前向行走算法完全不知道训练例程中正在进行交叉验证。**
六、评估未来表现 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
,按此折叠更新。我们还推进了训练集开始的索引,使得下一个测试折叠中的第一个条形将紧接在当前测试折叠中的最后一个条形之后。如果我们已经达到了在随后的折叠中将没有测试用例的程度,我们就完成了。当循环退出时,我们在returns
有nret
个连续的 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 回报时,我们的零假设通常是这个系统是没有价值的:它的真实预期回报是零或更少。
-
另一种假设通常是我们希望有效的情况。在目前的情况下,另一个假设是,我们的交易系统是好的,这是由一个非常大的积极的观察样本*均回报所证明的。
间接推理是这样工作的:
-
假设零假设为真,并计算在此假设下*均回报的理论分布(或我们的测试统计量是什么)。这是最难的部分。
-
使用这个分布,计算我们随机观察到的样本均值与我们获得的均值一样大(或更大)的概率。
-
如果这个概率很小,则断定零假设是错误的。
这是可行的,因为我们必须始终将无效假设和替代假设定义为互斥和穷尽。这意味着不可能两个假设都为真,这两个假设涵盖了所有的可能性。真正的情况总是非此即彼,决不是两者兼而有之,也决不是两者皆非。
基本逻辑是这样的:假设我们看到,如果零假设是真的,我们观察到的回报很可能不会这么好。在这种情况下,我们得出结论,另一个假设可能是正确的。
重要的是要明白,得到一个与零假设完全一致的结果并不能让我们断言零假设是真的,甚至可能是真的。无论我们观察到什么样的结果,我们都不能断言零假设是正确的。我们只能断言零假设可能是错误的,从而断言另一种可能是正确的。
这里有两个例子可以说明这种情况。假设有人在两个相同的大罐子里装满了糖豆,两个罐子的高度都一样。你仔细看着他们,试着做一个陈述。你能说它们含有相同数量的软糖吗?他们看起来非常非常接*。但很可能一个包含 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 1 , x 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. 计算偏差校正,它补偿必要的隐式转换的偏差程度。
-
计算加速度,它补偿隐式转换参数的方差依赖于其值的程度。
-
使用前面描述的百分位数方法计算下限和上限,然后根据这些修正修改分位点。
-
从排序的 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 **Param–pctile upper和pivot upper= 2 **Param–pctile 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
试验时,我们跟踪随机收益总体的夏普比率,总共是它们的nsamples
倍ntries
。在所有利润因素试验完成后,我们重复夏普比率的整个过程。没有简单的方法来计算理论夏普比率,所以我们使用这个人口值。为了确保准确性,我们用相同的随机种子开始利润因子试验和夏普比率试验,确保产生完全相同的一组损益。这些试验完成后,打印结果。
图 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(m,n–m+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_q
和high_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_q
和high_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_theory
和lower_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_q
和lower_bound_high_q
完成的。请再次记住,这些数量是概率,但因为我们模拟的 OOS 回报遵循均匀分布,它们也是与这些概率相关的分位数。如果一切正确,这两个测试应该分别以概率lower_bound_low_theory
和lower_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_q
和upper_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_q 和 low_q 的变量在下限和上限有相反的关系。为了那些可能对此感到困惑的读者,我使用短语 opt_q 和 pes_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%的缩减,我们会发现这一信息非常有用!
坏消息是,我们做不到这一点,至少不能达到我们希望的确定程度。我们遇到了前一节中困扰我们的相同情况,我们计算了基于概率的个人未来收益的界限。我们发现界限本身会受到随机误差的影响,所以我们必须用额外的概率陈述来限定我们的断言。这就是我们对未来提款的限制。这既不快速也不容易。或者说特别准确。但这将是一个非常有用的数字,我们将继续研究这个问题,在我们计算的时候一定要交叉手指。
直觉出错了
在进入正确界定未来提款这一相对复杂的主题之前,我们需要明确界定均值提款和界定实际提款之间的区别。前者是我们未来可以预期的*均下降。后者是我们实际经历的个人下降。后者将倾向于围绕前者,但个人提款很容易比*均水*差得多(当然,也不会差得太多)。出于显而易见的原因,我们主要关心的是我们的下一次削减会有多糟糕,而不是*均削减会有多糟糕。
这个问题提供了一个机会来展示直觉是如何轻易地将我们引入歧途的例子。考虑以下有缺陷的推理:
-
我们的回报是样本外的,因此是无偏的。
-
因此,我们的回报是我们未来预期回报的一个公*的代表。
-
提款取决于订单;一长串连续的亏损将会产生巨大的损失,而交替的盈利和亏损只会产生微小的损失。
-
未来的回报将类似于我们目前的 OOS 样本。只会出现两种不同。首先,在输赢的表象中会有一些随机性,有可能我们会比我们的 OOS 样本幸运地多赢几场,或者被诅咒多输几场。第二,输赢出现的顺序会不一样。这是影响未来削减的两个因素。
-
我们可以用计算机模拟这两种随机效应。我们从我们的回报中随机抽取替换样本,并评估其下降情况。然后反复做,几千次。我们获得的提款分布代表了未来可能的提款分布。例如,假设我们发现 5%的引导试验下降了 60%或更多。然后我们断言,未来我们有 5%的机会遭受 60%或更多的下降。
这个可靠的推理的致命缺陷在于第二步。请回顾“插曲:无偏到底是什么意思?”第 125 页。问题是统计意义上的无偏并不意味着大多数人理解的实际意义上的无偏。事实上,我们的 OOS 样本几乎可以肯定是有偏见的。这过于悲观了。或者乐观。我们不知道,但无论是哪种情况,它都是对未来回报的公*表示。我们称之为不偏不倚,只是因为过度的乐观和悲观是*衡的,两者都不偏不倚。
步骤 5 中的计算机模拟没有考虑到这样一个事实,即我们的 OOS 回报本身是一个随机样本,因此乐观或悲观,也许非常乐观。这是该算法没有考虑的一个巨大的变化源。因此,极端提款的可能性比计算机模拟所暗示的要大得多。当我们在第 267 页讨论提款计划时,我们会看到,对于灾难性提款,这种算法会低估其概率超过 10 倍。即使是适度的提款,其概率也可能低 2 倍。这是最糟糕的错误,因为它是反保守的。高估大规模削减的可能性会令人不安,但低估这种可能性可能是灾难性的。
自举下降界限
首先是坏消息:计算未来提款的概率界限非常慢,通常需要一亿次缓慢计算的迭代。对于一个已建立的交易系统来说,一个这样的计算通常可以在几秒钟到最多一分钟内完成,这是一个可管理的时间。但是,如果你想在训练算法中使用下降界限来优化交易系统的参数,你可能很容易就要花费几个小时甚至几天的计算机时间。这可能是一个交易杀手。我们的一个例外是,第 264 页步骤 5 中显示的速度非常快的算法可以用于训练算法,前提是满足两个基本且通常合理的条件。这将结合第 267 页提出的削减计划进行更详细的讨论。
现在又有一个坏消息:这些计算的结果可能不那么准确。像原始利润因子和夏普比率一样,基于提款的统计数据并不是非常适合自助。尽管如此,我们通常可以得到比什么都没有好得多的结果。即将展示的算法应该在每个市场交易者的工具箱中占有一席之地。
让我们简要回顾一下决定用一组观察到的 OOS 回报进行的计算与未来提款之间关系的三个因素。要知道,我们有一个未知的回报分布,我们的历史 OOS 数据和未来交易就是从这个分布中提取的。这是我们关心的三个因素:
-
我们的计算所基于的 OOS 回报集是来自可能回报总体的随机样本。
-
未来一段时间的缩减取决于从该人群中获得的收益和损失的大小和相对数量。
-
这种未来的减少取决于输赢出现的顺序。
第 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_conf
和Bound_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++ 中的数据挖掘算法 。假设我们正在训练或测试某个系统,它的性能取决于数据呈现给它的顺序。以下是一些例子:
-
我们有一个完整定义的交易系统,我们想在样本外衡量它的表现。在其市场价格历史中,价格变化的顺序非常重要。
-
我们已经提出了一个市场交易系统,我们必须优化它的一个或多个参数,以最大化它的性能。在其市场价格历史中,价格变化的顺序非常重要。
-
我们有一个模型,定期检查指标,并使用这些变量的值来预测市场波动的*期变化。我们想训练(优化)这个模型,或者在 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)测试我们的模型工厂。预测模型的置换检验以一种显而易见的方式属于相同的三个类别,所以在此讨论中我们将不区分它们。相反,我们将关注置换的特殊方面。
理解在将目标与预测集配对的环境中,对于绝大多数模型来说,训练数据出现的顺序是不相关的。只有预测集的与目标的配对影响训练。我们希望它们是并发的:我们将目标在给定时间的正确值与预测值的当前值配对。我们通过破坏这种配对来进行置换,随机重新排列目标,使它们与不同的预测集配对。当我们这样做时,有两个至关重要的问题,这两个问题将很快得到更详细的描述。
-
指标集不得相互置换,只能相对于目标进行置换。这保持了集合内的相关性,这对正确的测试至关重要。
-
在和两者中,一个或多个预测值和目标值不能有任何序列相关。一个或另一个中的序列相关性是好的,甚至是常见的,但它不能同时存在于两者中。
对于第一个问题,考虑这个玩具的例子。假设我们有两个预测指标: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 页,我们开始了对选择偏差的详细讨论。如有必要,请查看所有材料。这里我们展示了蒙特卡罗置换检验)是如何扩展到处理选择偏差的。为了将这个主题放在上下文中,这里有一个常见的场景。我们有几个相互竞争的交易系统,比如说两个或者几百个。也许它们是由不同的开发者提交给我们考虑的,或者也许它们都是相同的基本模型,但是具有不同的试验参数集。无论如何,我们从竞争者中挑选最好的。这个算法将回答两个问题。
-
一个不太重要但仍然有趣的问题是关于单个的竞争者。对于每个竞争对手(忽略其他竞争对手),如果该竞争对手实际上毫无价值,我们获得的绩效至少与我们观察到的一样好的概率是多少?这与上一节中显示的基本算法所回答的问题完全相同,针对每个竞争对手分别回答。
-
真正重要的问题是关于最好的(表现最好的)竞争者。假设所有的竞争者都毫无价值。如果我们测试了大量的样本,很可能至少有一个是幸运的,完全是随机的。因此,我们不能仅仅确定哪一个是表现最好的,然后使用可能被称为其 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 数据的完全指定的模型的方法,以及评估交易系统工厂质量的方法。现在我们来看一个更有趣的方法,用置换检验来收集交易系统质量的信息。这种方法不像以前的测试那样严格,它的结果通常应该有所保留。但是它的发展揭示了如何从一个交易系统中获得看似良好的表现,并且该技术也提供了一个未来可能表现的指标。
假设我们刚刚训练了一个交易系统,通过调整它的参数来最大化一个性能指标。我们可以将其样本内总回报大致分为三个部分。
-
我们的模型(希望如此!)已经学会了合法的技能来检测市场历史中的预测模式,从而做出明智的交易决策。这种表现很可能会持续到未来。
-
我们的模型还将一些噪声模式误认为是合理的,从而学会了对根据定义不会重复的模式的反应。这种被称为训练偏置的绩效组成部分不会持续到未来。
-
如果市场有一个整体的长期趋势(就像大多数股票市场一样,长期趋势是向上的),大多数训练算法都会倾向于利用趋势的头寸。特别是,它将有利于上升趋势市场的多头头寸和下降趋势市场的空头头寸。只要趋势持续,这种趋势的表现成分将持续到未来。
这最后一个部分值得更多的讨论,特别是因为它是一些交易系统开发者争论的主题。假设你在两个股票市场上分别训练了一个交易系统(优化了它的参数)。市场 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
,使其指向数组中最后一个需要洗牌的元素,并交换元素j
和i
。请注意,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()
来抵消所有四个价格数组。此栏将保持不变;排列从下一根棒线开始,这也是交易回报开始的地方。总共有nprices
–lookback
条可用于排列程序。第一笔交易可以在第一根棒线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 是非常令人印象深刻的。