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

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

  outcometick 有个 ot 命令行工具。ot run 可以用公开样本归档在本地跑回测,和线上使用同一个引擎,不要 key,也不花钱。官网、SDK 文档和 npm README 都把这条命令提供给还没决定是否付费的人。

  8 月 31 号这一天,我发现这条路其实坏了两层:第一层是数据读不到,第二层是修好数据之后,它会静默地只跑一小部分。

样本仓库推不上去

  给 polymarket 的公开样本仓库换样本日时,price_change 单个文件超过了 GitHub 的 100MB 限制,推不上去。我打算分片,先确认 SDK 文档是否需要跟着改。

  看了 cli/local-data.mjs,ot run 本来就支持一天多个文件:遍历目录、按日期和分类过滤、逐文件流式读行再合并。实测分片和单文件跑出来的事件逐条相同,所以分片不是问题。

  验证时发现,文档里的命令一直不能运行。样本仓库是扁平的 samples/<文件名> 结构,而 classifyPath 是看目录段来判断数据集的,所有文件一律被判成 dataset: "other",looksLikeArchive() 返回 false。文档上那条

1
ot run . --data ./polymarket-data-samples

  一直报错。cli/local-data.test.mjs 的 fixture 用的是真实归档的目录布局,没有覆盖扁平的样本目录,所以测试全绿。

样本统一放进 Release 完整包

  我最初想把小文件放进 git,大文件放到 GitHub Release。可用户如果直接在仓库页面点「Code → Download ZIP」,拿到的就是残缺数据。这比直接报错更糟:缺了大文件的归档仍能通过 looksLikeArchive(),跑起来后每个市场都没有盘口事件,报告照样生成,却毫无意义。

  最后改成 git 树里不放数据文件,数据全在 Release 的完整包里。Download ZIP 得到的内容无法运行,会明确报错。网站上的链接直接指向 releases/latest/download/<固定文件名>,资产名不带日期,换样本日也不用改站点。包内采用真实归档的目录布局(data/polymarket/daily/<dataset>/BTC-5m/…),让 classifyPath 能正常识别。

  polymarket 的包叫 polymarket-data-samples.tar.gz,predict 的仓库名是 predict.fun-data-samples,包名却是 predict-fun-data-samples.tar.gz,点变成了连字符。按仓库名去拼链接会返回 404。命令里也不再写 --date,它本来就是可选的,不传就跑归档里所有有数据的天,不用再随样本日修改日期。

生成首页预览时被 OOM 杀掉

  换样本日还要重跑 scripts/generate-samples.mjs,它从归档里取前几行做首页的数据预览,并算 sha256 印在旁边。上一次成功运行是 8 月 14 号。新的样本日选的是 08-25,那是 BTC 盘口采样从 500ms 改成 20ms 之后的第一个完整日,price_change 从 27.5 万行涨到了 390 万行、279MB。

  原来的 headLines() 注释写着「without inflating more than needed」,实际却执行 gunzipSync(整个对象).toString('utf8'),只为取前 3~7 行。在生产机上,它直接被 OOM killer 杀掉。我马上查了 pm2,所有服务都是 restarts=0,只有这个临时进程死了。换到内存更大的机器也没用,解压后的文本会超过 V8 最大字符串长度。这个脚本几个月才跑一次,输入还一直变大,不换数据就不会暴露问题。

  改成一次 GET 流式读:一边喂给 hash,一边只保留前 1MiB 用来提取预览行,长度和 sha256 都对着同一个响应验证。我本来的写法是先用 Range 读取前面几行,再另开一次 GET 算 hash,觉得是等价的。复审时被指出:两次请求之间对象如果被修复或覆盖,页面就会把一个 digest 印在不是从它来的那几行旁边,还显示 sha256 ok。改成单次 GET 后,刚写好的 Range 那一套连同 9 个测试整个删掉了。峰值内存 111MB,12 个样本全部校验通过。

数据能读了,然后只跑了 2 个市场

  当天晚些时候,我准备给一个量化资源列表提 PR,那个列表要求收录的项目有不需要支付信息的永久免费层。我不想在别人的仓库里写一句没验证过的话,就把 338MB 的样本包下下来,从头真跑了一遍 ot run。

  一天 BTC 5 分钟周期:markets 文件里 292 个市场,loadLocalDay 交出 289 个可跑的、3 个 dropped,总共 604 万个事件。而报告上写着 scanned.markets = 2,events = 253,退出码 0,没有任何警告。

  问题在 cli/commands/run.mjs 的 runHarness,原来是这样(省略了序列合并的部分):

1
2
3
4
5
6
7
8
child.stdin.on('error', () => {});
child.stdin.write(`${JSON.stringify({ ...job, outputKey })}\n`);
for (const m of markets) {
  const lines = m.events.map((ev) => JSON.stringify(ev));
  child.stdin.write(`${JSON.stringify({ market: m.market, n: lines.length })}\n`);
  for (const line of lines) child.stdin.write(`${line}\n`);
}
child.stdin.end();

  一个同步循环把整个归档写进子进程的 stdin,从来不看 write() 的返回值。Node 在缓冲区满的时候会返回 false,调用方应该等 drain 事件再继续写。同时 stdin.on('error', () => {}) 把 EPIPE 在内的所有错误都吞了。结果就是缓冲一满,后面的行就没再到达 harness,最后 stdin.end() 让它正常收尾。一份只基于 0.7% 市场的报告,看起来和完整的一模一样。

  我先确认影响范围。逐行对比线上 worker 向沙箱喂数据的实现后,发现那边一直是对的:会等 drain,也确实处理 error。所以付费 run 的报告和计费不受影响,只有本地 ot run 出了问题。

共用 stdin 写入器

  ot run 和线上队列在这之前已经分叉过八次,共用了解码器,其他部分仍各自实现。这次把 worker 的写入逻辑抽成 runner/stdin-writer.mjs 的 createLineWriter,两边共用。worker 注释里记录的两个问题也一起带了过来:

  1. 整个流只挂一个 error 监听器。每写一行挂一个 once('error') 而不摘除,会累积上千万个监听器。
  2. 出错时要唤醒正在等待的 drain。管道断掉的时候如果正卡在等 drain,drain 永远不会来,promise 永远不 settle,一直耗到整个墙钟超时才失败,而不是在断掉的那一秒就失败。

  核心就这么几行:

1
2
3
4
5
6
7
8
9
10
11
12
13
stream.on('error', (err) => {
  writeError = err;
  const wake = wakeDrain;
  wakeDrain = null;
  if (wake) wake();
});

return (line) => new Promise((resolve, reject) => {
  if (writeError) { reject(writeError); return; }
  if (stream.write(`${line}\n`)) { resolve(); return; }
  wakeDrain = () => { /* 摘掉 drain 监听,有错就 reject,否则 resolve */ };
  stream.once('drain', onDrain);
});

  worker 原有的 54 项测试全绿,说明抽出来的东西和原来一致。

校验实际回放的市场数

  修完写入逻辑后,复审又指出两处遗漏。

  第一条:修复只检查了写入失败,还需要确认市场是否全部回放。所以在 ot run 写归档之前,加了一道比对:harness 报上来的 marketsRun 必须等于喂进去的市场数,不等就不写归档。我用故障注入验证了一下,把写入器换回旧写法(这样不会产生任何写入错误),它报出 only 2 of 289 market(s) were replayed,没有产出归档。

  第二条:worker 原来只判断 marketsRun === 0,队列里如果出现同样的短报告,仍会被当成完成品发出去。我在 worker 的计费逻辑前加了同样的比对,不相等就报 E_RUNTIME 并全额退款。markets_run 是沙箱上报的,策略能影响它,但这个方向的 fail-closed 是安全的:少报只会退款,不会让策略白拿数据。退款本身幂等,重复失败也只退一次。这个修复不能只发 npm 包,worker 在另一台机器上,还要单独部署,我差点漏掉。

  两道校验的位置(CLI 的必须在 buildArchive 之前,worker 的必须在计费之前)都用扫源码的测试钉住了,并且做了变异验证:把条件改掉,测试必须变红。第一次做变异时我是直接删代码,结果语法坏了,node --check 先挂掉,测试根本没跑到。换成把 < 改成 === -1,语法有效、语义退回,才是一个有效的变异。

  最后端到端复验:真实的 338MB 归档,289 个市场、6044736 个事件全部回放,全量 1245 项测试通过。第二天把 E_RUNTIME 补进了文档的错误码表,之前客户端会收到这个码,文档里却没列。


outcometick 工程笔记(11/21)

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

下一篇:稳定币收款的两次事故:精确金额、币种等价和一张过期订单

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

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

稳定币收款的两次事故:精确金额、币种等价和一张过期订单