把策略写成能被测试的样子
- 说得出「同样输入必得同样输出」这条要求会把策略代码切成哪几块,以及每一块各自能用什么方式验证
- 认得出「函数内部自己去读当前时间、账户余额、最新行情」这个写法为什么既不可测又最容易藏进未来函数
- 分得清「不崩」「数值对」「性质成立」这三层断言各自能保证什么,不再把跑出结果当成结果正确
你写完了第一版策略,跑起来了,出了一条曲线。
然后你改了一个地方——把均线周期动了一下,或者顺手加了个成交量过滤——曲线变了。变好了一点点。
问题来了:你怎么知道它是因为你改的那个东西变好的?
你说不上来。因为你那份代码里,取数据、算指标、判信号、算仓位、记成交,全在同一个函数里从上到下淌下来。你改的是第 40 行,但第 40 行的结果会往下淌到第 120 行,中间还有几个 if 分支和两处 iloc[-1]。你没有任何办法把第 40 行单独拎出来问一句「你算得对吗」。
于是你只能靠一个非常弱的证据:它跑出结果了,而且结果看着还行。
这篇讲的就是怎么摆脱这个处境。不是讲测试框架怎么用——那是软件工程的常识,你大概率比我熟。这篇讲的是:在策略这个特定场景里,「可测」这两个字具体要求你把代码切成什么形状,以及为什么这个形状同时还是防未来函数的形状。
先约定一个说法:下面反复出现的回测,指的是拿历史行情把一套交易规则重放一遍,看按这套规则操作过去会赚多少。它是模拟,不是真下单,机制层面 回测到底是什么 那篇讲得更细。
一坨代码是怎么长出来的
没人一开始就打算写一坨。它是这么长出来的:
你先写了个取数据的几行,跑通了,很开心。接着你要算均线,最自然的做法是接着往下写。算完了要判断金叉,也接着往下写。判断出来了要下单,还是接着往下写。每一步都是「在能跑的东西上加一点」,这是最舒服的写法,也是最容易出成果的写法。
等你意识到不对劲的时候,这个函数已经一百多行,而且它有个特征:它对外部世界有五六个隐藏的依赖,而这些依赖都不在参数表里。 它自己去连了接口,自己读了系统时间,自己查了账户余额,自己读了一个全局配置。
一个函数只要有这种依赖,它就没法被单独运行——你想验证它,就得先把整个世界搭起来。而搭一次世界的成本高到什么程度?高到你宁可不验证,直接跑全量回测看曲线。
这就是那个恶性循环的起点:因为难测,所以不测;因为不测,唯一的反馈就是最终那条曲线;而那条曲线是几十个环节的叠加结果,它反馈不了任何具体信息。
第一刀:让信号计算变成一个纯函数
先说人话
菜谱和做菜是两件事。
菜谱是一张纸:写着放多少盐、炒几分钟。同一张纸给一百个人看,读出来的内容一模一样——它不依赖今天几号、你饿不饿、厨房有没有盐。
做菜是另一回事:它要动锅,要消耗真实的食材,做完了世界就变了,而且你没法「重做一遍看看是不是一样」。
策略里也有这两种东西。 「这根 K 线上均线是不是上穿了」是菜谱——它是对一段既定数据的一次纯计算,同样的数据进去,必须得出同样的结论。而「买入两百股」是做菜——它花钱,它改变账户,它不可重来。
把这两种东西塞进同一个函数,你就同时失去了菜谱的可复现性和做菜的可控性。
投资层:信号是一个判断,不是一个动作
这个区分不只是代码洁癖,它对应着投资上一个真实的分界。
你手工看盘的时候也在做这两件事。第一件是形成判断:这只票现在算不算符合我的标准。第二件是决定动作:我要不要买、买多少、这个价挂不挂得进去。老手会把这两步在心里分开,因为一旦搅在一起,就会出现那个经典的毛病——先想买,再去找支持买的理由。判断被动作污染了。
代码里的表现形式一模一样:如果你的信号函数里能读到「我现在还有多少钱」,那么迟早有一天你会写出「余额不够就把信号压掉」这种逻辑。这句话本身也许合理,但它属于下单环节,不属于判断环节。混进来的后果是,你的历史信号序列从此依赖于你的资金曲线,而资金曲线又依赖于历史信号——两个东西互相咬住,你再也没法回答「这套判断标准本身好不好」。
信号和订单这条分界本身怎么划、划错了会怎样,信号和订单是两回事 那篇专门讲。这里只用它的结论:判断层必须是纯的。
系统层:一张表进,一张表出
看真实系统怎么落地这件事。
freqtrade 的策略接口里,指标计算和信号判断被固定成了同一种形状:接受一张行情表,返回一张行情表。 算指标的那个方法往表上加指标列,判入场的那个方法往表上加信号列,判离场的再加一列。它们不连接口、不查余额、不看系统时间——所有输入都在那张表里,所有输出也都在那张表里。
这个形状带来的直接好处是:你可以手工造一张只有二十行的假表喂进去,看它吐出来的那几列对不对。不需要交易所,不需要账户,不需要网络。验证成本从「搭一个世界」降到了「造二十行数据」。
更有意思的是这个框架不光鼓励你这么写,它还在运行时检查你到底有没有这么写。它的策略结果校验器会在调用你的函数之前,先把输入表的行数、最后一根 K 线的收盘价和时间戳记下来;等你的函数返回之后,再拿返回的表跟这三个值对一遍。对不上,就报错或者告警。
这一小段代码防的是什么?防的是你在信号函数里偷偷做了不该做的事——把表截短了、把最后一行的价格改了、把时间轴动了。这些操作单看没有一个会报错,但它们全都会让「同一份数据反复计算得到同一个结果」这条性质失效。框架在这里做的事,本质上是把「纯函数」这个约定从口头承诺变成了运行时断言。
这个思路你完全可以抄进自己的代码里,而且不需要任何测试框架:在你的信号函数外面套一层,进去之前记三个数,出来之后对一遍。十几行的事,能挡掉一整类你自己看不出来的错。
第二刀:外部状态从参数进,不要在函数里自己读
先说人话
你让人帮你做一道判断题:「现在这个点,该不该出门?」
他自己去看窗外、自己去摸手机看时间、自己去查天气——那你事后想复盘「他那天为什么做了这个决定」,就复盘不了了,因为窗外的天早就变了。
换个做法:你把「现在几点、外面多少度、有没有下雨」写在一张纸上递给他,他只看纸做判断。现在你随时可以拿同一张纸再问他一遍,答案必须一样;你也可以改一个数字,看他的答案怎么变。
这是同一个人、同一套判断标准,唯一的差别是信息从哪来。
投资层:内部读取的地方,就是未来函数最爱藏的地方
这条做法真正的分量不在测试,在防作弊。
想一下「函数内部自己去取数据」这个动作。它取的时候,用的是什么条件?绝大多数情况下是「最新的」。而在回测里,「最新的」这三个字是个陷阱——你的模拟世界现在是三年前的某一天,但你的数据源手里有到今天为止的全部数据。一个不带时间参数的取数调用,默认拿到的就是整段历史,包括那一天之后的部分。
于是你写了一句看起来无害的「取一下这只票的数据,算个均值」,而这个均值是用未来算出来的。程序不会报错,回测照跑,曲线好看得让你想上真钱。这就是前视偏差最典型的工程入口——它不是有人故意作弊,是一个省事的写法带出来的。这类偏差为什么只往好处错、为什么极难自查,前视偏差 那篇讲得很透。
现在把这两件事对起来看,你会发现一个挺漂亮的巧合:
一个函数如果它需要的东西全部从参数进来,那么它就既是可测的,又是没法偷看未来的。
因为「未来的数据」不会自己长到参数表里。谁调用这个函数,谁就得把参数准备好,而准备参数的地方是集中的、显眼的、容易审的。你审十几处调用点,比在几千行里找哪句取数没带时间条件,容易一个数量级。
可测性和防前视,在这里是同一件事的两个说法。 这是我认为这一篇里最值得记住的一句。
系统层:把「当时的世界」打包成参数
freqtrade 那一串策略回调的参数表,值得当成范例读一遍——不是记住名字,是看它们都在往里传什么。
要不要确认这笔入场、要不要改这个委托价、要不要提前离场、这笔挂单是不是该超时撤掉——每一个这样的决策点,框架都会把当前时间、当前价格、这笔交易的对象、当前浮盈比例这些东西,作为参数塞给你。你的函数只管拿参数做判断,不需要自己去问「现在几点」,也不需要自己去查「这笔单赚了多少」。
这个设计有个连带效果:这些回调全都可以在没有交易所的情况下单独调用。 你自己构造一个时间、一个价格、一个假的交易对象,就能问它「这种情况下你怎么判」。你想测「浮亏到某个程度是不是会触发离场」,就传一个对应的浮盈比例进去看返回值——不用真的跑一段行情把价格跌到那个位置。
Vibe-Trading 的回测引擎基类把这个思路推得更彻底一点。它把执行环节的一堆判断切成了一组很小的方法:这只票这个方向今天能不能成交、这个价加上滑点(你看到的价和真正成交的价之间那点差额)之后是多少、这笔交易的费用是多少、这个原始股数按整手规则要取到多少。每一个都短,每一个都只从参数拿输入——代码、方向、当前这根 K 线、原始数量、价格。
切成这样之后有两个好处。第一个是可测:每个小方法都能拿几组手工构造的输入单独问一遍,费用算错了、取整取错了、滑点方向加反了,当场就能看出来,不需要跑完整段回测再从收益里倒推。
第二个好处更有意思:这组小方法是各个市场的差异所在。 这个仓库里有一堆按市场分的引擎子类——A 股、期货、外汇、加密、几个海外股票市场——它们共用同一套主循环,各自只覆写这几个小方法。A 股有涨跌幅限制和整手规则,加密没有;不同市场的费用结构完全不同。把差异集中到几个短方法里,等于把「这个市场的规矩」变成了一组可以逐条核对的东西,而不是散落在主循环各处的 if 分支。
顺带一句:成交能不能发生、按什么价成交,这些判断本身的假设有多脆弱,回测里的成交假设 那篇排了一把尺子,值得对照着看自己的引擎站在哪一格。
第三刀:固定的小样本,比一整年真实数据有用
为什么不用真实数据测
很多人写单元测试的第一反应是:截一段真实行情,跑一遍,把结果存下来当基准。
这个做法有三个毛病。一是慢,慢到你不愿意每次改动都跑一遍,于是它就废了。二是脆,数据源换个版本、复权口径一变,基准就全对不上,而你分不清是代码改坏了还是数据变了——复权就是把送股、分红这些事件在历史价格上造成的跳变抹平,让不同时间段的价格可比,而抹平的算法不止一种。三是它测不到你真正担心的情况——你担心的是停牌那几天、涨跌停封死那天、启动期数据不够那几根,而随手截的一段真实数据里,这些情况可能一次都没出现。
正确的做法反过来:手工构造一小段数据,专门为了触发某一种情况。
比如你想验证「涨跌停封死时不该成交」这条逻辑,就造三根 K 线:第一根正常,第二根开盘价正好顶在限制边缘,第三根回落。整个测试数据不超过五行,你可以直接把预期结果手算出来写进断言里。这就是「金标准」的来处——不是从别的程序里抄的,是你自己用笔算出来的。
freqtrade 的测试数据目录里躺着几十个小文件,命名一眼就能看出用途:真实交易对的短样本用来测通用流程,而那些叫 UNITTEST 的明显是造出来的,同一个「币对」备了几种不同的时间粒度和几种不同的存储格式。这个组合方式本身就是信息量——它说明这些数据不是拿来「看策略赚不赚钱」的,是拿来「看代码在各种边角情况下走没走对分支」的。
随机的东西也要能复现
有些环节天生带随机:蒙特卡洛、自助抽样、参数搜索的采样。
它们看起来违反了「同样输入必得同样输出」,其实不违反——只要你把随机种子也当成输入的一部分。 Vibe-Trading 那个结果验证模块里,几个随机方法都把种子做成了显式参数,注释里直接写明是为了可复现。
这条的实际价值超出测试本身:没有固定种子,你和同事跑同一份代码会得到不同的数字,然后你们会花一下午争论谁的对。 有了种子,先确认种子一样,数字还对不上,才是真的有 bug。
为什么「能跑出结果」离「是对的」还差三层
这是这一篇最想说清楚的一件事。
代码验证有三个高度,很多人只做了第一层就以为做完了:
第一层,不崩。 程序跑完了,没抛异常,出了一张图。这一层只证明了一件事:你的类型对得上、索引没越界。它对「计算得对不对」零信息量。而策略代码的绝大多数错误恰恰不抛异常——数据坑不抛异常,前视不抛异常,费用算错也不抛异常,它们只是安静地改掉数字。
第二层,数值对。 拿手工构造的小样本,把预期结果算出来,跟程序的输出比。这一层能挡掉一大批实打实的错误:均线算漏了一根、费用双边收成了单边、取整取到了错误的方向。
但第二层有个天花板:它只能验证你想到的那些情况。 你手算的时候能想到的边界,程序里大概也想到了;你没想到的,测试里也不会有。
第三层,性质成立。 这一层不问「结果等于多少」,而问「结果之间该满足什么关系」。这是策略代码里最值钱的一类断言,因为它能抓到你根本没想到的错。
举两个真实系统里的例子。
freqtrade 检测前视偏差的工具,用的就是纯粹的性质断言。它压根不读你的策略代码写了什么,它的推理只有一句:如果你的计算没有偷看未来,那么把数据从后面截掉一段再跑一遍,前面那部分的结果应该一模一样。 于是它跑两遍,比对前段的信号和指标,对不上的地方就是嫌疑点。
注意这个思路的漂亮之处:它不需要知道「正确答案是多少」,它只需要一个必须成立的关系。这就绕过了第二层那个天花板。
同一个目录下还有另一个工具,针对的是另一条性质:递归型的指标(这一根的值依赖上一根的值)需要一段热身数据才能收敛。它的做法是给同一份数据配上几种不同长度的前置区间,各算一遍,比较同一个时间点上的指标值差了多少。如果差得明显,说明你的热身长度不够,那么你回测最前面那一段的信号全部是不可信的。 这个坑的机制在 指标启动长度 那篇有系统的说法。
Vibe-Trading 那个验证模块则是把性质断言做在了结果层:把已完成交易的盈亏顺序打乱重排很多遍,看真实那条路径的风险调整收益在这堆随机排列里排第几。这问的不是「收益对不对」,是「这个收益和随机重排比起来,有多不寻常」。同一个模块里还有自助抽样给出的区间估计,和分时间窗看表现是否一致的检验。这类方法的原理和它们各自能回答什么,蒙特卡洛检验 那篇讲得更完整。
三层摞起来才有意义。而且有一句话必须说明白,否则前面全白讲:
单元测试只能保证代码做了你写的那件事,它保证不了你写的那件事本身是对的。
你把「买入手续费按单边收」写成了代码,测试通过,只说明代码确实按单边收了。至于单边这个假设本身对不对,测试一个字都答不了。代码的正确性和策略的正确性是两个独立的问题,可测性只解决前一个。别把测试全绿当成策略能用——那是完全不同的一类证据。
一个可以照着切的分层
把前面几条合起来,一份策略工程大致会切成四层,每层的验证方式不一样:
| 层 | 干什么 | 纯不纯 | 怎么验 |
|---|---|---|---|
| 数据接入 | 取数、对齐、复权、补缺 | 不纯(碰外部) | 存一小份样本文件当固定输入,只测「读进来之后长什么样」 |
| 特征与信号 | 算指标、判条件、出信号列 | 必须纯 | 手工造几十行表,手算预期,逐列比对 |
| 组合与下单 | 信号转成买卖多少、名额怎么分 | 纯(状态从参数进) | 构造几种持仓状态和账户状态,看输出的目标仓位 |
| 执行与账户 | 能不能成交、成交价、费用、取整 | 纯(当前 K 线从参数进) | 每个小方法单独喂几组输入,含边界情况 |
这张表里最需要守住的是第二行那个「必须纯」。上下两层碰外部世界是没办法的事,但信号层一旦不纯,你就失去了整套体系里唯一那块能被反复复现的地基。
还有一个判断标准,比任何规则都好用:看你想验证某个环节的时候,需要准备多少东西。 如果答案是「得先连上数据源、先有个账户、先跑三年」,那这个环节的设计就是有问题的,不管代码写得多漂亮。反过来,如果答案是「造五行数据调一次函数」,那你以后每改一次都愿意验一遍——而愿不愿意,才是可测性真正的衡量标准。
本文讲的是策略代码的工程组织与验证方法,不构成任何投资建议、买卖信号或收益承诺,不推荐任何数据源、框架或标的。文中关于开源项目的所有描述均来自对指定快照版本源码的阅读,不是运行结果,也不保证在你手上的版本、你的数据源、你的市场上同样成立——具体行为请以你手上那个版本和你自己的核对为准。尤其要强调:代码测试全绿只说明程序按你写的执行,它不说明你写的规则本身正确,更不说明这套规则能赚钱。历史表现不预示未来收益。完整风险提示见 免责声明。
小结
- 一坨代码的核心症状不是「长」,是它对外部世界有一堆不在参数表里的隐藏依赖。有这种依赖,就没法单独运行,也就没法单独验证。
- 信号计算必须是纯的:一张表进,一张表出,不连接口、不读时间、不查余额。真实框架不光这么设计,还会在运行时对一遍行数和最后一根 K 线,把这条约定变成断言——这十几行你可以直接抄。
- 外部状态从参数进,不要在函数里自己读。 这条同时解决两个问题:函数变得可测,同时「未来的数据」再也没法自己长到参数表里。可测性和防前视在这里是同一件事。
- 测试数据要手工构造,不要随手截真实行情。 真实数据慢、脆、而且大概率不包含你真正担心的那些边角情况。几行数据加一个手算出来的预期值,比一整年历史有用得多。带随机的环节把种子当成输入的一部分。
- 验证有三层:不崩 → 数值对 → 性质成立。第三层最值钱,因为它不需要你事先知道正确答案,只需要一个必须成立的关系(截断重跑结果该一致、热身够长指标该收敛),从而能抓到你根本没想到的错。
- 最后这条别忘:测试全绿只证明代码做了你写的事,证明不了你写的事是对的。 策略假设的对错是另一个独立问题,得用另一套证据去谈。
一句话说完这篇:把「做判断」和「动手」拆开,让做判断那部分只吃你递给它的东西、不自己去外面拿,然后用几行你亲手编出来的数据反复问它同一个问题——它每次都答得一样、答得跟你笔算的一样,你才算真的知道自己写了什么。
本文为投资教育整理,关键数据与结论请结合下列权威来源验证。
- freqtrade 源码快照 b3404c9(2026-08-18):freqtrade/strategy/interface.py 中指标与信号填充方法及各回调的参数构成、freqtrade/strategy/strategy_validation.py 的 StrategyResultValidator、tests/testdata/ 目录下的固定样本文件、freqtrade/optimize/analysis/lookahead.py 与 recursive.py 的比对思路(仅读源码,未运行) ↗
- Vibe-Trading 源码快照 3a752d5(2026-08-04):agent/backtest/engines/base.py 中 can_execute、apply_slippage、calc_commission、round_size 等按参数取值的小方法与各市场子类的覆写关系、agent/backtest/validation.py 的三种结果层验证及其固定随机种子的做法(仅读源码,未运行) ↗