回测框架为什么要分三层
一、一个说不清哪里错了的结果
写回测最省事的做法,是从取数到出净值一口气写完:拉行情、算信号、按信号买卖、统计收益,一段脚本从头跑到尾。第一次跑通的时候感觉很好,因为屏幕上出现了一个数字,而这个数字看起来像是证据。
问题要到第二次运行才暴露。把参数换一下,或者把数据补一天,净值变了。变了多少看得见,为什么变看不见——是行情口径动了,是成交价换了日子,还是信号本身就不稳?三种可能混在同一个数字里,没有任何办法把它们分开。
回测出错的地方本来就不止一处。常见的有六类:用当日收盘的信号在当日成交,这是前视;用今天还存续的券池去回溯历史,这是幸存者偏差;滚动窗口用了中心对齐,这是未来函数;复权价前后混用;成本漏算;以及拿债券的估值中间价当成可以成交的价格。这六类不住在同一个地方,它们分别藏在取数、撮合和判断三个环节里。把三个环节焊成一段脚本,等于把六种病因装进一个不透明的盒子,之后每一次追问都只能靠重跑和猜测来回答。
二、把一条链切成三段
后来我把这条链拆成三层,每层只对一件事负责。
最下面一层是数据层。它只做一件事:把不同来源、不同形状的行情,统一成同一张日期乘代码的宽表。固收个券的估值、隐含评级与余额,债券指数与股票指数的日行情,ETF 的复权价与净值,个股的前复权价——四类资产进来,一张表出去。这层结束,取数就结束了。
中间一层是逻辑层,也就是账户与撮合。它不知道自己在回测什么资产,只消费上面那张宽表和一组目标权重,再把权重翻译成真实的成交记录。资产无关是这一层的前提,也是同一套引擎能覆盖四类资产的原因。
最上面一层是判断层,装三样东西:信号、组合构建、可信度验收。它只输出权重,不碰现金,也不碰成交价。
拆完之后一个很直观的变化是每层的体量:引擎两百四十多行,内置策略一百二十多行,可信度验收两百七十多行,数据路由五百行出头,全部业务框架合起来十二个,每层四个。每层都是一个人一个下午能读完的规模。这件事的意义不在于代码好看,而在于任何一层出问题时,我知道该打开哪个文件。
三、分开之后,问题才变得可以回答
第一个能回答的问题是成本吃掉多少。成本在引擎里是一行公式:固定佣金加滑点,再加一项按成交额占比开方的冲击。这个公式的位置是唯一的,所以把成本设成零再跑一遍,两条净值曲线之间的差就是成本。合在一起写的脚本里,成本散落在每一处买卖的加减上,想单独剥出来,只能改代码。
第二个是改一个阈值要不要重跑取数。判断层的策略只输出一个权重字典,改阈值不触碰数据层,取数结果因此可以缓存、可以复用,改一次参数的成本是秒级的。
第三个,也是最关键的,是哪一层错了。三层各自有各自的症状:数据层出错,表现是某个代码在某一天没有数据,或者同一只券在表里出现两行;逻辑层出错,表现是成交价用了不该用的那一天;判断层出错,表现是样本内漂亮、样本外塌掉。三种症状指向三个不同的文件,而不是指向同一段几百行的脚本。
四、有三件事必须在引擎里写死
分层解决不了的,是那些写错一次就全盘无效的规则。这类规则只能放进引擎,不能指望写策略的人每次都记得。
第一件是成交价必须来自未来。引擎里策略拿到的上下文只暴露到当日为止,撮合在下一个交易日用开盘价完成。这条规则的位置在接口本身,写策略的人没有途径读到后面的数据。
第二件是停牌和涨跌停不能成交。这里靠一张可交易掩膜挡住,买不进也卖不出,而不是用一个理想价格假装成交。
第三件是债券的估值价不等于成交价。这条机器挡不住,因为估值中间价在数据层面完全正常。唯一的办法是在报告里显式写下这个假设,让读结论的人自己决定要不要打折。
五、判断层凭什么要单独成层
判断层独立的理由很直接:验收需要一个统一的门槛。
如果信号和撮合焊在一起,每套策略都可以用自己顺手的方式算出漂亮的数字,而没有一个地方能统一地问一句:这个数字是不是从上万次尝试里挑出来的最好的那一次。
现在的门槛是三件事同时成立:多重试验校正后的可信度不低于 0.9,滚动样本外的夏普不低于 0.5,过拟合概率不超过一半。第一条有个容易被忽略的前置条件——必须如实记录试验次数。调了二十组参数再报最优结果,和只跑了一组就报结果,考验的不是同一个东西。
用这套流程跑的第一个策略是 ETF 动量轮动,八只宽基与行业 ETF,从 2021 年初到 2026 年 8 月。结果是年化 7.79%、夏普 0.31、最大回撤 21.9%,配十三折滚动样本外。
把这组数字交给门槛,结论是不该发布。夏普 0.31 离门槛里那条绝对线(夏普 1.0)差着三倍不止。这其实是这套结构第一次跑通时最值钱的产出:它没有给出一个能赚钱的策略,给出的是一个不能发布的判定,而且这个判定有可以逐条追问的出处。
六、分层不是免费的
代价有三个。
一是契约。三层之间靠一张宽表相连,这张表的字段一旦定下来,所有数据源都得按它输出。债券要多给收益率、久期、评级和余额四列,ETF 要多给净值与复权价,这些约定写下来容易,维护起来要一直盯着。
二是边界会被反复越界。最常见的诱惑是在数据层做筛选,比如只取某个评级以上的券。这样做很自然,但它把幸存者偏差搬进了数据层,而判断层完全看不见——数据层交出来的表本身就是干净的,干净得看不出筛选发生在哪一天。所以数据层只做三件事:取全、对齐、去重。券池怎么形成属于判断层,而且只能用当日可见的信息。
三是你会想把它再拆细。这个方向的尽头是每层四块,块与块之间再定接口。拆到这一步,好处是每一小块都能单独验收,代价是接口数量跟着涨。对一个人的回测框架来说,四乘三这个粒度大体够用,再细下去,维护接口的时间会超过写策略的时间。
七、从哪一层开始
如果要新搭一套回测,我的顺序是先写契约,再写引擎,最后写策略。
契约就是那张宽表:哪些字段、什么类型、日期怎么对齐。定了它,数据层就有了验收标准,逻辑层可以在不知道数据来源的情况下开工。
策略那一侧的接口只留一个方法:给定当前上下文,返回目标权重。加一个新策略等于加一个类,不动引擎,也不动数据。
第一件要跑的是等权基准,先放策略是后面的事。没有基准的回测数字没有意义,而基准恰好是验证下面两层有没有写错的最便宜方式——如果等权组合的净值都跑不出合理的形状,问题一定不在策略上。
三层分开的尽头,落在「这个结果能不能信」这件事上:它从一个态度问题变成流程问题。前者问的是人够不够谨慎,后者问的是流程有没有出口。
文中观点仅为个人实践观察,不构成任何投资建议。