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

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

  这两天(8 月 23 日到 24 日)我在给 outcometick 加在线回测。用户贴入策略代码,系统在沙箱里用真实归档重放,跑完生成报告,再按扫描的「市场日」扣预付 credits。一个市场日就是一个币种、一个 UTC 日、一个市场周期。

先定的几件事

  语言先只支持 Python 和 Node.js。我也考虑过 Go 和 Rust,但编译过程本身会执行不可信代码(build.rs、过程宏、go generate)。还得 vendor 一份离线依赖缓存,每门语言再配一个静态分析器,成本太高。所以我把「语言」做成可插拔的,以后再加。

  执行器放在独立机器上。网站那台机器有 Stripe 的 key、生产数据库和 R2 的凭证,需要和执行陌生人代码的回测机器隔离。

credits 账本

  一开始我就没打算用一个 balance 字段加加减减。余额是 backtest_credit_ledger 里 SUM(delta) 算出来的,这张表只追加不修改。几条规则:

  1. 提交时在同一个事务里建 run 行并扣额度,按 subject 拿 advisory lock。分开写就会出现「有扣款没任务」或者「有任务没扣款」。
  2. 外部触发的加额度都带 ref 和唯一索引,Stripe 重投同一个事件也只加一次。
  3. 对外承诺「只有产出报告的任务才扣 credits」。被拒、崩溃、超时、worker 重试用尽,全额退款。退款按 refund:<run_id> 幂等。实际扫到的市场日比报价少,就退差额。

  写完后复审了几轮,账本这里抓出两个真问题。

  第一个:退款额度原本取自 harness 写出来的 result.json 里的 market_summaries。但 harness 和策略跑在同一个进程里,策略只要把这部分输出压掉,一次真实跑完的任务就会被判成「没扫到数据」并退款,等于免费用数据。后来定了一条规则:进账本的数字只能来自 worker 自己取到、自己喂进去的市场日,沙箱报上来的数只能放在展示字段里。

  第二个是计费单位不一致。报价按市场日算,worker 却报「市场数」。一天 BTC 1 小时周期有 24 个市场,但只算 1 个市场日。于是实扫量永远远大于报价量,被 Math.min 钳回满额,「按实际扫描退差额」那段逻辑从来没被触发过。

  另外还有两个并发相关的:run 建好到源码存进 R2 之间,worker 可能领到一个 source_key 为空的任务。我加了一个 staging 状态,源码落盘后才转成 queued。但这又带来新问题:提交中途挂掉的话额度会被永久占住,所以 worker 空闲时要扫一遍超时的 staging 并全额退款。还有终态转换原来是先读后写,租约过期的 worker 可能覆盖掉接手者已经写好的终态,改成了 SELECT ... FOR UPDATE。

两个引擎,三次静默分叉

  撮合引擎我不得不写两份,JS 一份,Python 一份。原因是每个事件的预算只有 400µs,装不下一次跨进程调用的往返,Python 策略只能配 Python 引擎。

  两份实现可能不一致,同一个策略换种语言就会得到不同报告,也不一定报错。我在 runner/conformance/ 里写了一致性测试,把同一份 job 分别喂给两个 harness,启动真实子进程,逐笔成交、逐个字段比较。当天就抓到两处:

  • JS 的 Math.round 是半数向上,Python 的 round 是银行家舍入。价格量化要经过它,刚好落在半档的价格在两边会匹配到不同档位。
  • Python 侧把 dict 直接交给了策略,照文档写 tick.value 当场 AttributeError,同样的 JS 却能跑。

  第三次是 ctx.p(策略参数)在 Python 侧漏了一层包装,结果 /docs/sdk 上每一个 Python 例子照抄都会在第一个 hook 崩掉。一致性测试没抓到,因为每个用例都写着 params: {},策略没有读参数。现在补了两个专门用属性和下标读 ctx.p 的用例。

偷看未来

  策略只要拿到「当时」不该知道的东西,整份回测报告就没意义。这类漏洞前后漏了四次,每次入口都不同。

  第一次:worker 给策略的市场元数据里带着已结算的 outcome,on_market_open 直接把整个对象交出去了。实测一个策略读到答案、买中会赢的一边,跑出 900% 收益,报告里每个数字都跟着作废。

  第二次:job 文件和事件文件挂载在容器的 /job、/events 上。静态分析禁了 open、os、pathlib,但白名单里的 pandas 一句 read_json('/job/job.json') 就读出来了,拿到了全部 outcome 和所有事件文件路径。

  第三、四次是事件数组本身越过了收盘时刻,以及 worker 自己取收盘报价时用了没截断的数组。修前三次的时候,我每次都没往「还有没有第四条路」去想。

  最后改了数据流:结算前的市场视图一律去掉 outcome,只有 on_settle 能看到;job 和事件全部走 stdin,不写到策略能打开的路径;回放循环逐条读取,Node 用 readSync(0),Python 用 sys.stdin.readline(),进程里任何时刻只有当前这一行。原来 harness 会先把整个市场的事件读入数组再回放,策略却在那之前 import。它只要在导入时给 JSON.parse 或 Array.prototype.push 打补丁,就能在第一个 hook 前拿到整个市场的 tick。改完后,未来数据在回放到对应时刻前不会进入进程。

静态分析不是安全边界

  JS 这边我写了一个基于 acorn 的静态分析器,禁掉 Function、process 这类东西。复审陆续找到新的绕过:globalThis.constructor.constructor、计算属性名拼接 ["con"+"structor"]、默认参数捕获 onTick(ctx, t, fetch = fetch)、再到用 Object.getOwnPropertyDescriptor 反射取回 constructor。这些入口都已堵上,Object 上的方法现在走白名单,但仍可能有遗漏的反射入口。

  静态分析器只负责提前检查误用和强制确定性,安全隔离由容器承担。容器能挡住出网,但不能禁止读取系统时钟,这部分仍要靠静态检查来保证报告可复现。计费只使用 worker 自己掌握的数据。

上线:缺 unzip,以及 acorn 放错了地方

  23 号把回测、ot CLI、npm 和 PyPI 两个 SDK 包一起合进 main 部署。第一次部署在测试阶段失败,4 个用例挂了:runner/archive.test.mjs 里有几个用例 shell out 到系统的 unzip,生产机上没装。好在 deploy.sh 的回滚是干净的,代码、.next、node_modules 全部恢复,pm2 根本没重启,站点没受影响。产品本身不需要 unzip,所以我没给机器装工具,而是让这几个用例在缺工具时 skip 并打印原因。

  部署前自查还发现 acorn 被我加成了 devDependency。可 createApp 会无条件注册回测路由,那条链上静态 import 了 acorn,装不到的话整个 API 都起不来。deploy.sh 用的是 npm ci,而 npm ci 在 NODE_ENV=production 下会跳过 devDependencies。生产机恰好没设 NODE_ENV,当时还能启动,现在已挪到 dependencies。

容器里那条路,从来没跑通过

  24 号把 worker 搬到新机器上(Docker + gVisor),才发现一件事:容器里跑策略这条路,从产品建起来到那天,一次都没有成功过。

  worker 是这样起沙箱的:

1
2
spawn('docker', argv, { stdio: ['pipe', 'ignore', 'pipe', 'pipe'] })
// 然后读 child.stdio[3] 拿结果

  结果通道用的是第四个文件描述符 fd 3。但 Docker 只把 stdin/stdout/stderr 转发进容器,第四个管道属于 docker CLI 本身,容器里 fd 3 是关着的,harness 每次写结果都是 Bad file descriptor。

  之前一直没发现,是因为所有测试要么直接启动 harness 子进程,要么走本地开发用的非容器模式 OT_SANDBOX=none,没有一个真正进过容器。项目说明文档里甚至写着「runSandbox 那一层还没有端到端跑通过,不宜盲改」。这句话放了一整天,我期间还发了包、上了页面,却没有马上补测试。

  修掉 fd 3 之后,同一条路上又接连冒出三个洞,每个都是前一个修好才走得到:

  1. docker run 没带 -i,容器压根没有 stdin,harness 直接 no job on stdin 退出。
  2. 镜像 tag 硬编码成 1.4.0,在 worker.mjs、build.sh 和测试里抄了三份。harness 是烘焙在镜像里的,所以 tag 其实是协议版本:宿主上留着旧镜像,就是旧 harness 配新 worker,每个任务静默产出空报告然后全额退款,看起来像是策略的问题。现在只有 backtest-contract.mjs 里一处定义。
  3. 往 stdin 写数据时每写一行挂一个 once('error') 且从不摘除,700 个市场日是上千万行。

  结果通道最后改到了 stdout,每行带一个 per-run 的 HMAC。两个 harness 在加载用户代码之前先把 stdout 拿走:Python 用 os.dup(1) 加 os.dup2(devnull, 1);Node 没有 dup2,就把 globalThis.console 整体换成一个冻结的哑对象。只替换 console.log 这些方法不够,console._stdout 就是那条流,而且能过静态分析。

  然后补了 runner/sandbox.test.mjs,实际执行 docker run,两种语言都跑。写完它在 worker 上显示 4 pass,其中 2 个是假绿:我写的是 { skip: null },而 node 的测试框架只要看到 skip 这个键就把用例标成 skipped。条件跳过得写成 SKIP ? { skip: SKIP } : {}。

  监听器泄漏那个测试也差点白写。我一开始用 setInterval 定时采样 listener 数量,故意退回旧实现验证时,MaxListenersExceededWarning 都打出来了,测试还是绿的。改成跑完之后直接数,旧实现残留 5002 个监听器,这才红。

后续验证

  文档里标着「这一层没测过」的地方列入待办。新增回归测试后,也退回旧代码跑一遍,确认能检出原来的问题。


outcometick 工程笔记(9/21)

上一篇:一把只显示一次的 API key 是怎么丢的

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

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

一把只显示一次的 API key 是怎么丢的

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