outcometick 的回测除了 Polymarket,也支持 Predict.fun 的加密货币涨跌市场。这周(09-11 到 09-13),我连续修了两个结算问题:判断市场是否结算的条件用错了,平局也没有被当成结果。两种情况都会丢掉市场,报告里写着「outcome could not be read」,所在的市场日却照样按整天收费。
Predict 市场长什么样
回测读的是我自己采集的归档。Predict.fun 的 markets 行大概是这样:
1
2
{ market_id, category_slug, interval_label, start_sec, end_sec,
start_price: "627.505", end_price: "628.265", status: "OPEN" | "RESOLVED" }
价格是普通十进制字符串,结算规则就是比较 end_price 和 start_price:高了是 UP,低了是 DOWN。
runner/events.mjs 原来的逻辑是:只有 status === 'RESOLVED' 才读 outcome,否则就认为市场尚未结算,把 outcome 设为 null,后面的 marketUnusable() 再丢掉它。
09-11:status 不是结算标志
问题出在采集侧。predict_markets.status 记录的是「我最后一次读到这个 category 时,上游给的状态」。结算回填则由 end_price IS NULL 驱动:市场一旦拿到 end_price,就永久退出重读队列,status 从此不再更新。如果最后一次读取时上游还是 OPEN,它就永远停在 OPEN。
采集侧文档其实早就写过:全表没有「status 是 RESOLVED 但缺 end_price」的行,所以是否结算一律看 end_price 是否存在。这个关系只成立一个方向:有 end_price 一定已经结算,status 不是 RESOLVED 却不代表没结算。回测代码正好用了不成立的反方向。
用 09-08 这一天实测:全币种 1245 个 Predict 市场,全部都有 end_price,但只有 1104 个的 status 是 RESOLVED。旧判据丢掉了:
- 5 分钟市场 39/873,4.5%
- 15 分钟市场 21/291,7.2%
这些市场的价格字段已经足够读出 outcome,报告却写着读不出来。
原有测试「a Predict market that is still OPEN has no outcome」也用了这个判据,理由写着「它的 start/end 是那一刻的实时报价」。这个理解不对,我把测试改成三条:
- 冻结的 status 不能掩盖已经结算的 outcome;
- 没有 end_price 就没有 outcome。这一条必须留着,它是防止策略看到未来的那道线,还没结算的市场不能提前给出答案;
- 平局丢弃,并写明真实原因。
第 3 条只是当时的临时状态。原注释说丢弃平局是因为「平台单方面结算,不知道结果是哪边」,这个理由也是错的。实际是 end_price == start_price 时平台按平局处理,而两个引擎只认识 UP 和 DOWN,没有第三种状态。那天我只修了判据,平局暂时继续丢弃并如实计入 coverage。
当时我在注释里写了一句:别用「挑一边」来修平局。平局在 5 分钟市场里占 0.53% 到 1.20%,随便挑一边,就会把其中一半的市场算反。
09-13:平局是第三种结果
确认过平局的兑付规则:UP 和 DOWN 每份都兑付 0.5 美元。于是 outcome 增加了第三种取值 TIE。
改动本身不大,但要改的地方分散在三处,而且必须完全一致:
1
2
3
4
5
// runner/engine/portfolio.mjs
export const contractValue = (side, outcome) => {
if (outcome === OUTCOME_TIE) return 0.5;
return outcome === side ? 1 : 0;
};
另外两处是 Python 引擎 otengine.py 的 contract_value,以及独立核对报告用的审计脚本里手写的 payout。审计脚本故意不 import 引擎的任何代码,所以这条规则在那边要自己再写一遍。
回测有 JS 和 Python 两个引擎(Python 策略用 Python 引擎),之前已经出过几次两边静默分叉,所以有一套一致性测试:把同一份 job 喂给两个 harness,逐笔比对成交和报告。这次加了一个平局对冲的场景,两边都要算出 0.5。
报告里的指标也一起改了:edge、brier、calibration 和三条基准,之前都隐含了「结果非 0 即 1」,现在统一走 contractValue,平局按 0.5 算。
SDK 的类型里也加了 Outcome,策略在 on_settle 里能看到 TIE。沙箱镜像的 tag 提到了 1.16.0,因为引擎是烘焙在镜像里的。
缓存失效:第一版是错的
麻烦在解码缓存。worker 会把解码好的市场日存到本机磁盘,省掉每次 run 的解压和解析。旧缓存里的 Predict 市场是按旧规则解码的:平局被丢了,status 冻结的市场也被丢了。改完规则,这些缓存必须失效。
第一版给每天的 day.json 加了 tiesSettled 字段。读取缓存时没有这个字段,就判定过期并重新解码。复审指出两个问题:
writeDecodedDay不会覆盖已经存在的最终目录(为了原子性,它是写临时名再 rename)。所以被判过期的旧条目永远换不掉,每次读都会冷解码,永远如此。- 如果部署后需要回滚,旧代码根本不认识这个新字段。它会照常读新写入的条目,再用旧引擎去结算,把 TIE 两边都按 0 美元算。
测试只覆盖了判定函数,没有走「旧条目 → 判过期 → 重新解码 → 再读命中」的完整过程,所以两个问题都漏掉了。
最后把 Predict 的缓存目录从 v2/predict/ 换成 v2/predict.r1/,用一个 VENUE_CACHE_DIR 表按平台映射。新代码只读新目录,回滚后的旧代码仍读旧目录,避免混用两种解码结果。
没有提 DECODER_VERSION,因为那会让 Polymarket 的缓存也全部作废。Polymarket 不会出现平局,这次也没有改变它的解码结果。
SDK 发版顺序
本地的 ot run 和线上的 worker 用的是同一个引擎,公开 SDK 的类型也变了,所以要先发 SDK 新版,再部署 worker,否则本地和线上的结果会不一致。
这里又踩了一下:发版脚本 release-sdk.mjs 要求工作区干净,而且拒绝「当前版本已经等于目标版本」。我原本计划把新版本号直接写进这次的 diff 一起提交,这个顺序执行不了。正确的做法是提交时版本号保持旧值,然后在干净的工作区上跑 release-sdk.mjs 提版。
在线上真跑一次
部署后我用一把当天就吊销的测试 key,在线上跑了 Predict BTC 15 分钟、09-08 这一天。第一个 run 日志里出现了 settled ... TIE,市场没有被丢弃。后来调整策略让它在平局市场上真的成交:10 份,成交价 0.51891,结算 exit_px 0.5,outcome 是 TIE,pnl 为 -0.1891,正好是 (0.5 - 0.51891) × 10。
本地拿新旧两版 SDK 对比同一天,新版多出 21 个市场:1 个平局,加上 20 个 status 冻结在 OPEN 但早已结算的市场。旧版 SDK 发布时 end_price 判据还没修,所以两个修复的效果都能在这里看到。
换路径等于这个平台的缓存全冷
部署完后,有人问我「数据都预热了没有」,我才发现换路径后 Predict 的缓存全是冷的。predict.r1 里只有线上测试解出来的 1 天,可售数据却有 91 天。
冷的后果不只是慢。每个 run 有取数预算,历史上冷解码撞满 60 分钟预算的 run 会以 E_BUDGET 失败并全额退款。下一次定时预热在次日 05:00 UTC,而且整条预热服务是 Polymarket 先跑,就算缓存是热的也要 191 分钟,Predict 要三个多小时后才轮到。这中间所有 Predict 付费 run 都会走冷解码。
所以我单独起了一个只跑 Predict 的预热(和定时预热用同一把 flock,nice、低 IO 优先级)。按开头 16 分钟的速度估算,大约每天 14 分钟,91 天要 21 个小时,而默认 240 分钟的预算只够热 17 天左右。于是重新起了一个显式给预算的单元 --budget-min 911,让它在 03:45 UTC 之前停下,把 flock 让给 04:00 的币安数据任务和 05:00 的夜间预热。
实际比估算快得多:2952 个预热单元全部完成,0 失败,用了 491 分钟,一次跑完。越早的日子能跑的形状越少,前 16 分钟推出来的速度偏悲观。predict.r1 最后是 91 天、1285 个 day.json、11G。
旧目录 v2/predict/ 的删除守卫也写错了,只要求「新目录里至少有 1 个 day.json」。虽然计划等预热完成再删,我还是按这个条件删了。新代码已不读旧路径,删除不影响命中,但这个条件没有检查预热是否完成。清理旧缓存时应检查新目录的天数是否等于可售天数。
后续操作
后续改变解码结果时会换缓存路径,并单独预热对应平台,等汇总行确认完成。
outcometick 工程笔记(13/21)