蒙特卡洛检验:把交易顺序打乱一千次,看你的成绩还剩多少
你按一套规则做了三十笔交易,赚了。翻开记录一看,前面十几笔顺风顺水,中间有过一段难受的回撤,最后又拉回来了。资金曲线整体向上,最深的那次回撤看着也还能忍。你的结论很自然:这套规则是能用的。
先别急着下结论。换个问法——如果这三十笔交易的盈亏数字一模一样,只是发生的先后顺序不同,你的体验还会是这样吗?
把「先赚的那几笔」挪到最后,把中间那几笔亏损集中到开头,总账一分钱都不会变,可你在第八笔的时候可能已经吓得停手了。这件事一旦想明白,你会开始怀疑一个更根本的问题:你看到的那条漂亮曲线,有多少是规则的功劳,有多少只是这一次的排列刚好长这样。
蒙特卡洛检验,做的就是这件事:把顺序打乱很多很多次,看看你实际拿到的这一条,在所有可能的排列里排第几。
先说人话:一副牌和一个顺序
想象你和朋友玩扑克,一整晚打了三十把。到最后你赢了一千块。
现在设想有个平行世界,牌还是那些牌,输赢的金额还是那些金额,只是发生的顺序被重新洗过。在某些平行世界里,你开局就连输八把,钱包见底,中途愤而离桌——尽管按完整顺序走下去你本该赢一千。在另一些平行世界里,你开局连赢,越打越顺,最后照样赢一千,但整晚心情舒畅。
同样的一批结果,同样的总账,完全不同的过程。 而你唯一亲身经历的,只是这无数个平行世界中随机的一个。你却拿这一个,去评价自己牌技如何。
蒙特卡洛这个名字来自赌城,它的意思很朴素:遇到算不清的问题,就随机试很多次,用试出来的分布当答案。 这里的「很多次」不是修辞,是真的让电脑跑上千遍。打乱顺序、重算一遍成绩、记下来;再打乱、再算、再记。跑完之后你手上不再是一个数字,而是一整片分布——你会看到「这套规则在各种运气下大概能长成什么样」,而不只是「它这一次长成了什么样」。
投资层:为什么顺序能改变一切
这一节值得慢慢看,因为它牵扯到你会不会在错误的时刻放弃。
第一,总收益跟顺序无关,但回撤跟顺序关系极大。 一串数字相加,怎么加都是那个和。可最大回撤——也就是资金从最高点掉下来最狠的那一次——完全取决于亏损有没有扎堆。三笔亏损散在三年里,你几乎没感觉;三笔亏损连着发生,你的回撤可能直接翻倍。规则一个字没改,你的煎熬程度差了一倍。
第二,你不是活在期末,你活在过程里。 回测算完给你一个终值,那是上帝视角。现实中你是一格一格往前走的,走到某一格如果亏得受不了,你就下车了——下车之后,后面那段本该属于你的收益跟你再无关系。所以真正决定你能不能拿到那条曲线的,不是终点有多高,是路上最难受的那一段有多难受。这也是仓位与破产风险那篇反复强调的事:先活下来,才谈得上复利。
第三,「连赢七次」经常什么都不说明。 抛一枚公平硬币一百次,出现连续七次正面几乎是必然的。人看到连续七次会立刻编出一个故事——「我摸到规律了」。可这只是排列本身自带的结构。想深挖为什么人会在随机里看见规律,大数定律与赌徒谬误那篇讲得更细。
第四,也是最容易被忽略的:漂亮的资金曲线本身,就是一种排列。 你不会去检验一条难看的曲线,因为它一眼就被淘汰了。留在你桌面上的,往往正是那些顺序恰好排得好看的。这是一层筛选,而蒙特卡洛就是用来把这层筛选量化出来的工具。
系统层:它在真实系统里长什么样
Vibe-Trading 把这类统计检验单独放在一个验证模块里,文件开头写得很清楚:这里有三件互相独立的工具,蒙特卡洛置换检验、Bootstrap 夏普置信区间、滚动前进分析。第三件是另一个话题(见 滚动前进验证),这里只看前两件。
置换检验:p 值其实就是数数
它的做法朴素得让人意外。先把每一笔已完成交易的盈亏取出来,排成一个数组;算出实际顺序下的成绩作为基准;然后进入循环——每一轮把这个数组随机打乱一次,重新算一遍成绩,和基准比一比,比得上就记一笔。
核心的几行是这样:
for i in range(n_simulations):
shuffled = rng.permutation(pnls)
sim = _path_metrics(shuffled, initial_capital)
if sim["sharpe"] >= actual["sharpe"]:
sharpe_count += 1
if sim["max_dd"] >= actual["max_dd"]: # less negative = "better"
dd_count += 1
跑完之后,sharpe_count / n_simulations 就是输出里那个 p 值。
所以 p 值到底是什么?就是「随机打乱出来的成绩里,有多大比例至少和你实际那次一样好」。 没有任何玄学。这个比例越小,说明你实际拿到的这条路径在所有排列里越靠前;越接近 1,说明随手一洗牌就能洗出比你更好的结果。
这里的「成绩」在源码里由一个小函数算出,也值得看一眼它衡量的是什么:
equity = initial_capital + np.cumsum(pnls)
returns = np.diff(equity) / equity[:-1]
sharpe = float(returns.mean() / (std + 1e-10) * np.sqrt(252))
peak = np.maximum.accumulate(equity)
max_dd = float(((equity - peak) / peak).min())
第一行是把盈亏逐笔累加成资金曲线,第三行的夏普比率可以粗略理解成「每承担一单位波动换来多少收益」(更细的分辨见 夏普与索提诺),最后两行是记录历史最高点、再看跌得最狠那次有多狠。注意 np.sqrt(252) 里的年化天数在源码里是个可配置参数,当前默认按一年 252 个交易日折算;那个 1e-10 只是防止除零的工程细节,别当成什么金融含义。
关键在于:这两个指标都依赖累加顺序,而总盈亏不依赖。 打乱重排之所以有意义,正是因为它在「总账不变」的前提下,专门去动那些对顺序敏感的东西。
前端把结果画成一张扇形图:中间是你实际的那条曲线,周围是打乱出来的一大片路径,以及它们的分位带(源码里取了从低到高几档分位线)。你一眼就能看见自己那条线是穿过这片云的中间,还是贴着上沿走。当路径矩阵规模过大时,源码会跳过这份画图数据以免撑爆内存——这是纯粹的工程取舍,不影响 p 值本身。
界面上还会给一个「是否显著」的标记,判据用的是统计学里那个流传最广的惯例阈值。这里要提醒一句:那个阈值是学术圈的约定俗成,不是自然规律,源码里也就是一行比较。把 p 值的第三位小数拿来纠结,或者把「刚好过线」当成通行证,都是误读。
Bootstrap:换一种打乱法,问另一个问题
同一个文件里的第二件工具改了打乱的对象。它不再动顺序,而是从你的收益序列里有放回地重新抽样——抽出和原来一样多的样本,允许同一天被抽中好几次,也允许某些天一次都不被抽中:
sample = rng.choice(returns, size=len(returns), replace=True)
boot_sharpes.append(_sharpe(sample, bars_per_year))
跑上千轮,就得到一大堆夏普值。取两端的分位数,就是置信区间——用人话说:如果这段历史只是无数种可能样本里的一种,那么夏普这个数字大致会在多宽的范围里晃。输出里还有一项叫 prob_positive,就是这堆模拟里夏普大于零的比例,直白得可爱。
它的测试文件里有一条断言把机制点破了:置信度定得越高,算出来的区间越宽。这很符合直觉——你要求「更有把握地覆盖住」,就只能把网撒得更大。一个报出来的夏普数字,背后其实是一段区间;区间宽到跨过零,那这个数字就没什么好炫耀的。
三种「蒙特卡洛」,问的不是同一件事
「蒙特卡洛」这个词被用得太滥,同一个名字底下至少三种做法,回答的问题完全不同。摆在一起才看得清:
| 做法 | 随机的是什么 | 回答什么问题 |
|---|---|---|
| 置换检验(打乱顺序) | 已有交易的先后次序 | 我这条曲线的形状,有多少是排列排出来的 |
| Bootstrap(有放回重抽) | 收益样本本身 | 我报出来的这个指标,有多不稳 |
| 路径模拟(如几何布朗运动) | 按假设凭空生成未来路径 | 在某个假设下,未来可能有多糟 |
| 你的日常 | ——(没有随机,只有唯一一次) | 用发生过的这一次,证明自己有能力 |
第三种在该项目的风险分析文档里出现过,用来做风险价值的第三种算法:给定漂移和波动率参数,生成大量假想路径,再取分位数。它和前两种有个本质差别——前两种只在你真实发生过的数字里重新排列组合,第三种是拿一个数学假设造出全新的数据。 造出来的东西有多可信,完全取决于那个假设有多离谱。如果你假设收益服从正态分布,那模拟出来的极端行情永远不够极端,这一层的坑在正态分布与肥尾里讲得很透。
最后一行才是重点:你自己做投资时,手上永远只有一次实际发生的样本。蒙特卡洛的全部价值,就是给你造出那些本来不存在的平行世界,让你看见「我这次的结果,在运气的分布里站在哪」。
随机数是可复现的
顺带说个容易让人困惑的点。这类模拟都会接受一个随机种子参数,测试里专门有一条:同样的种子跑两次,p 值完全相同。
「随机」和「可复现」听起来矛盾,其实不矛盾——电脑生成的是伪随机数,给定起点,整条序列就是确定的。这在检验里是必须的,否则你今天算出的结论明天就变了。
freqtrade 里有个不相干但同思路的细节可以对照着看:它有个把交易对列表打乱的过滤器,源码里明确区分运行模式——回测时套用固定种子以便结果可比,实盘时不设种子。同一份随机,在需要复核的场景里被钉死,在需要真随机的场景里被放开。
它治不了什么病
这一节比前面所有内容都重要,因为这是最常见的误用。
置换检验不能证明你的策略不是过拟合的。 想清楚它的原假设是什么:「同样这一批盈亏数字,随机排个序」。它随机的只是顺序,那批盈亏数字本身被当成既定事实照单全收。所以如果你的规则是靠反复调参、从历史里挑出一批恰好赚钱的交易得来的,那么这批数字怎么打乱,每一条路径都在赚钱——p 值会漂亮得不得了,而策略依然是过拟合的。它检验的是路径,不是样本的来路。 这一层要靠别的办法对付,见过拟合:把噪音当成规律。
打乱顺序这个动作本身带着一个强假设:交易之间可以互换。 现实里不能。同一段暴跌行情里的几笔交易天然相关,它们的亏损扎堆不是巧合,是同一个原因造成的。打乱之后,这种相关性被人为抹平了,模拟出来的分布因此会比现实更温和——扎堆的坏事被打散了。对趋势跟踪这类「收益高度集中在少数几段行情」的做法,这个抹平尤其失真。
模拟里的最坏路径,不是最坏情况。 重排只能用你已有的那些数字,你的样本里没见过的灾难,它一个也变不出来。一段只覆盖平稳行情的历史,无论打乱多少次,都模拟不出一次真正的崩盘。
交易笔数太少,这套检验没有意义。 源码对此设了硬门槛:少于三笔直接返回错误,连算都不算。道理很简单——三笔交易一共只有六种排列,你数出来的那个比例粒度粗得没法用。笔数越少,p 值越不可信,这跟回测到底在算什么里说的样本量问题是同一回事。
p 值小,不等于会赚钱。 它只说明「这条路径不太像是随手排出来的」。一套统计上显著、但预期收益覆盖不掉交易成本的规则,仍然是赔钱的规则。显著性和盈利能力是两个维度。
别把「p 值好看」当成放大仓位的理由。 检验通过说明的是「顺序上不像纯运气」,它对样本本身的可靠性、对未来行情会不会变、对你的成本假设是否合理,全都一无所知。真实世界里更常见的顺序是反过来的:人先决定要加仓,再去找一个检验来给自己背书。跑检验的正确姿势是准备接受坏消息,而不是收集通行证。
你能立刻用上的那一半
不写代码的人,其实也能拿走这套思路的核心。
翻出你自己的交易记录,把每一笔的盈亏抄成一列数字。别管日期,随手换几个顺序,用纸笔或表格重新累加一遍,看看资金曲线会长成什么样。你大概率会看到某几种排法下,中途的回撤深到你根本扛不住。那个深度才是你真正承担的风险,而不是你实际经历的那一次。
再往前一步:如果连你自己都承认「换个顺序我就下车了」,那说明当前的仓位对你来说本来就偏重。风险管理不是等亏损来了再反应,是提前按最难受的那几种排列来定量。这条线索接的是风险管理卷里那套仓位纪律。想把这件事从「感觉扛不住」变成一个数,破产概率测算器干的就是同一件事——它按胜率、赔率和单笔仓位反复模拟,告诉你在坏排列里被打光的概率有多大。这条线索接的是风险管理卷R里那套仓位纪律。
小结
- 蒙特卡洛检验 = 把发生过的结果随机重排很多次,用得到的分布回答「我这一次的成绩,在运气里排第几」。
- 打乱顺序不改变总盈亏,但会大幅改变回撤和夏普——因为这两个指标依赖累加的先后,而总和不依赖。 你活在过程里,不活在期末。
- p 值不是玄学,就是数数:随机排列中成绩不输给你的那部分占了多大比例。界面上的显著性标记用的是学术惯例阈值,那是约定不是真理。
- 三种同名做法要分清:置换检验换顺序、Bootstrap 换样本、路径模拟造新数据;前两种只在真实发生过的数字里折腾,第三种的可信度全押在假设上。
- 它的天花板很明确:随机的只是顺序,所以治不了过拟合;打乱抹平了交易间的相关性,会让模拟比现实更温和;样本里没有的灾难,重排也变不出来;交易笔数太少则粒度太粗,源码干脆拒绝计算。
一句话记住这篇:你只经历过一种顺序,别把这一种当成你的本事。
本文所引源码与文档均来自上述快照版本,是阅读代码得出的机制说明,不是运行结果;具体行为请以你手上那个版本为准。回 量化与 AI 投研概念库 看其它概念各自在说什么;本站内容为投资教育,不构成任何投资建议,边界见 免责声明。
本文为投资教育整理,关键数据与结论请结合下列权威来源验证。
- Vibe-Trading 源码快照 3a752d5(2026-08-04):agent/backtest/validation.py、agent/tests/test_validation.py、frontend/src/components/charts/ValidationPanel.tsx、frontend/src/components/charts/MonteCarloPathsChart.tsx、agent/src/skills/risk-analysis/SKILL.md ↗
- freqtrade 源码快照 b3404c9(2026-08-18):freqtrade/plugins/pairlist/ShuffleFilter.py ↗