Posts 回测为什么慢,以及 Python 策略为什么一次都没成功过
Post
Cancel

回测为什么慢,以及 Python 策略为什么一次都没成功过

  回测上线后,8 月 24 日到 27 日主要在排查超时、运行失败和报告准确性问题,有几处最初判断错了方向。

正常策略触发每事件预算超限

  回测对策略有一个性能约束:每个事件的处理时间预算是 400µs,持续超标就拒绝。24 号自己拿测试 key 在页面上试,页面自带的示例策略直接被拒了,理由是「每事件预算超限」。

  当时的判定是「超限比率」:先采样 200 个事件,之后超限比例超过 1% 就拒绝。一个 BTC 15 分钟市场大约 4472 个事件,200 只是前 4%,而惰性导入、首次调用这些一次性开销正好都落在开头。实测示例策略 151606 个事件里只超限 7 次,占 0.005%,但其中 3 次落在前 200 个里,就是 1.5%,超线了。

  我把采样底线从 200 提到 2000,觉得修好了。结果到生产上端到端跑一遍,同一个错误又回来了:示例策略平均 72µs,限额 400µs,被拒的理由是 1.1% 的事件超限。

  这次我没再猜,同一个策略、同一个镜像测了几组:

1
2
3
4
宿主机               avg 0.2µs   零次超限
容器内,机器空闲     avg 7.8µs   max 766µs
容器内,另一核被占   avg 20.9µs  max 4090µs
两次 hrtime 本身     宿主 0.107µs / gVisor 4.461µs

  超限点出现在第 171、2368、3201 个事件,没有集中在开头,提高采样底线解决不了。worker 机只有 2 核,沙箱占 1 个 vCPU;worker 同时还在解压归档、往 stdin 喂事件。预算又是在 hook 两侧按墙钟计时。事件一旦被抢占,就会记成「策略在这里花了 4 毫秒」,超限比率里混入了调度等待时间。

  改成判「最近 2000 个事件的均值」。不用全程均值,是因为它会被稀释:前面 18000 个 8µs、后面 2000 个 2000µs,全程均值 207µs,照样放行,可最后那两千个事件一直是预算的 5 倍。窗口大小也不是随便选的,一个 S 微秒的尖峰会把 W 个事件的窗口均值抬高 S/W,实测最大尖峰 18ms,限额 400µs,所以 W 至少要大于 45。文档里原来写的 400 µs p99 也改成了「持续」,对外写一个实际不执行的口径,每次拒绝都没法解释。

为什么 30 天的回测一个都跑不完

  页面上原来有 30/60/90 天三个快捷选项。我想改成可以输入天数,先去量了一下为什么大区间的 run 这么慢,结果连着推翻了自己好几次:

  1. 以为是策略慢。实测策略处理中位数 12µs,几乎可以忽略。
  2. 以为是延迟面板要把同一段数据重放 6 遍。这是真的,但它只占回放阶段。
  3. 瓶颈在 R2 到 worker 的带宽,只有 1.9~3.5 MB/s,而一个市场日的原始归档大约 112MB。30 天光下载就要 25 分钟,而一个 run 的墙钟上限是 20 分钟。三个选项一个都跑不完,全部在取数阶段被杀掉然后全额退款。

  并发下载也没用,并发 1、4、8 的耗时分别是 32.1s、31.5s、31.5s,链路就这么宽。

  于是在 worker 本机加了对象缓存,一个市场日的取数从 32.3s 降到 0.2s。然后瓶颈又换了地方,变成 CPU 解码:gunzip 5.6s,解析 141 万行 9.5s,归类排序 5s,每个市场日 21 秒,每个 run 都从头来一遍。

解码缓存:难的是「它对应哪些输入」

  解码后的市场日用 gzip 保存,大约 57MB 一天,比原始归档还小。缓存需要记录对应的输入,否则可能读到错误的派生结果,而且没有报错。复审抓出四个问题:

  1. 缓存 key 里没有输入信息。
  2. 输入指纹漏掉了结算流对象。这些对象要先解码 markets 才知道要哪几个,所以指纹不可能在查缓存前算全,只能把实际读过的对象记下来,读缓存时跟当前 catalog 对账。
  3. 对账只检查「记录过的对象还在不在」,漏了「catalog 里新增了一个对象」。catalog 是可变的,重新导出本来就是修正数据的方式,所以必须双向比。
  4. 重建结算流时只看了存活的市场,漏了因为缺结算对象被丢掉的那个。而它恰恰是缺的对象以后可能被补上的那个。

  缓存命中时用硬链接,零拷贝,但部分失败后回落到冷解码时会用同名文件写入,把共享的 inode 截断,缓存内容已损坏,manifest 却仍有效。现在冷解码一律写临时文件再 rename。热命中重建结果时我写了 unusable: [],「这一天丢了一半市场」没有记录,报告看起来完整,仍按整个市场日计费,也没列出缺口。加 gzip 读取分支的时候忘了 import pipeline,1158 项测试全绿,因为没有测试实际读过 .gz 文件。

  缓存和预热脚本写完、测完后,我还漏了把它接进 worker。前面「16 天变成瞬间完成」的推算没有在实际 worker 路径上验证。

  最后的验收 run 是 16 个市场日:热缓存取数大约 25 秒,回放 12.8 秒一个市场日(之前是 106 秒)。

Python 策略从来没成功过

  26 号查排队问题时,我翻了数据库里的 run 记录,发现 python@3.14 的回测在生产上一次都没成功过:Python 0 次完成、17 次失败,Node 5 次完成。17 次全部全额退款,没有资金损失,也因此没有任何告警。

  根因在 harness.py 的 emit(),它是一次不检查返回值的 os.write。生产用的是 gVisor(runsc),在它下面,超过 64KB 管道缓冲的写入会短写:

1
2
3
runsc  python os.write  returned  65536 of 700001   丢了 634465 字节,exit=0,stderr 为空
runsc  node   writeSync returned 700001 of 700001   Node 内部会循环写完
runc   python os.write  returned 700001 of 700001   只有 gVisor 会短写

  结果行里 market_summaries 每个市场一条,大约 500 个市场就超过 64KB,而 polymarket 一个市场日就有 386 个市场。尾巴被丢掉,worker 等不到换行符,一条结果行都收不到,最后报「没有回放任何市场日」。另外还有 8 条失败是 worker 侧的 EPIPE,看起来毫不相干,其实是同一个根因:那些 run 跑了几百个市场之后被拒,拒绝信息也在那条超长行里被截断了,harness 退出了而 worker 还在往里写。

  我加了循环写完的 write_all。这个问题一直没暴露,是因为原有容器测试每个用例只发一个市场,结果行大约 1KB,永远碰不到 64KB。现在补了 700 个市场的用例,还断言结果行确实超过 64KB,免得 fixture 没越过边界,测试却空过。

  修完之后我自己又踩了一次。给日志预算取默认值时,我从 api/lib/backtest-contract.mjs import 了 LIMITS,可沙箱镜像里只 COPY 了 runner/ 目录。这行在开发机和 worker 上都能解析,因为两边都是完整仓库;到了容器里必然 ERR_MODULE_NOT_FOUND,而且在加载策略之前就死了。npm test 1195 项全绿。现在加了一个扫 Dockerfile COPY 行的测试,断言镜像里的文件不 import runner/ 以外的东西,纯文件读取,不需要起容器。

  中间看到 Node 容器测试也全红,两次输出里的镜像大小又不同(265MB 和 1.32GB),我以为测试用了没构建完的镜像。重跑后仍然全红,没有证据支持这个判断。

报告到底准不准

  跨引擎一致性测试只能证明 JS 和 Python 两边一样。它们按同一套设计写,完全可能一起错。27 号我又写了独立核对脚本 scripts/audit-report.py。它不 import 引擎、harness、API 的任何代码,只读取解码后的归档、币安现货和 report.json,从头重算,再逐笔对比。

  第一轮跑出 8 条不一致,查下来大部分是核对脚本自己的问题。其中 4 条源于同一个误解:我把「市场」当成全局唯一的,但归档按天组织,跨 UTC 午夜的市场会同时出现在两天的目录里。于是成交被匹配了两遍,基准线把 12 个市场各算了两份。修法不是放宽判据,而是去读 worker 是怎么建市场表的(按 id 去重、后写覆盖先写),按同一个口径重算,基准立刻逐分吻合。还有一条是报告端的 avg_hold_ms 用 Math.round 取整,我却按两位小数给容差。价格量化那里我又用了 Python 的 round(),而引擎是 JS 的 Math.round,这正是前几天刚在两个引擎之间踩过的坑。

  撮合约定按发布的 SDK 规范固定,对不齐就判失败,不能根据报告挑最吻合的约定,否则核对就变成了循环论证。实测按规范的约定命中 100%,其余三种分别是 2%、43%、0.8%。

  第一份 run 是示例策略,所有仓位都持有到结算,逐档吃单的代码一次都没跑过。所以我又专门写了一个 JS 策略提交到真实队列:每笔下单量远大于最优档挂单量,并且在结算前平仓。最后两份 run 都是 polymarket BTC 5 分钟、15 天、4321 个市场:

 示例策略(Python)吃穿多档 + 提前平仓(JS)
成交1676 笔,全部持有到结算1676 笔:892 结算 / 784 提前平仓
fills1676 行,全部单档2862 行,最深走了 7 档
逐笔复现1676/16761676/1676
fills 逐行1676/16762862/2862

  18 个指标、4 个校准桶、3 条基准线、滑点的全部字段逐项吻合。结算流没走到收盘那一刻的市场,一律记为「未验证」,不设时间阈值来放行。

  脚本自带 75 项自测,其中 20 条是反例:篡改报告里任一字段,核对必须变红,包括一份把仓位、盈亏、全部指标和基准一起按错误仓位自洽改掉的报告。这些用例用于确认核对器能检出错误报告。


outcometick 工程笔记(10/21)

上一篇:outcometick 回测沙箱上线记:容器里那条路从来没跑通过

下一篇:ot run 只回放了 289 个市场里的 2 个,退出码还是 0

This post is licensed under CC BY 4.0 by the author.

outcometick 回测沙箱上线记:容器里那条路从来没跑通过

ot run 只回放了 289 个市场里的 2 个,退出码还是 0