从 6 月初开始,我一直在业余时间做一个叫 outcometick 的小产品。它采集 Polymarket 和 Predict.fun 上加密货币涨跌(Up/Down)市场的 tick 级数据,按天归档,提供数据订阅,后来又加了在线回测。现在有了第一批付费用户,也踩了不少坑,趁还记得写下来。
为什么做这个
Polymarket 上有一类市场很适合做量化:「BTC 在接下来 5 分钟/15 分钟是涨还是跌」。这类市场一天开几百个,结算规则很明确:看 Chainlink 喂价在窗口末尾的 TWAP 是否高于开盘价。我想研究相关策略,但很快发现没有现成的历史数据。盘口变化、成交、结算喂价,平台都不提供历史记录,市场一结束就没了。
没有数据,只能自己采。采了一段时间,我觉得有同样需求的人应该不止我一个,就把采下来的数据整理成了一个产品。
它现在是什么样子
产品本身不复杂,分三块:
- 数据订阅:订阅滚动最近 30 天的归档,通过 HTTP API 按平台、数据集、币种、周期筛选并下载文件。更早的历史数据按天一次性买断,自己选日期区间,按天计价。
- 在线回测:把策略代码(JavaScript 或 Python)贴进来,在隔离沙箱里用真实归档重放。跑完会生成一份报告,包括逐笔成交、收益曲线和校准表。按扫描的「市场日」扣预付的 credits。
- SDK 和命令行:npm 和 PyPI 上各有一个包,可以在本地用公开样本数据跑同一个引擎,不需要花钱。
技术栈没什么特别的。站点用 Next.js,API 用 Node.js + Express,数据库是 PostgreSQL,归档放在 Cloudflare R2。付款接了 Stripe(卡和微信支付),也支持通过币安收稳定币。回测 worker 单独放在一台机器上,用 Docker + gVisor 跑用户提交的代码。
印象最深的几个坑
代码量上来以后,最费时间的不是写功能,而是排查那些「看起来对、其实一直是错的」地方。下面几个坑我印象最深。
1. 明文 key 只存在一次
API key 在数据库里只存 sha256,明文只在创建时出现一次。这样做没问题,但每条交付路径也因此只有一次机会。
上线后不久就出了事。一位中文用户付完款,跳回成交页看到了 key,然后顺手点了页面上的「中文」切换语言。切换链接没带上 URL 里的会话参数,页面一刷新,key 就没了,再请求只会得到「已经签发过」。最后翻了 nginx 日志,才还原出他的操作路径。
后来补了三道防线:成交页拿到 key 后立刻写进 sessionStorage;Stripe 回跳时使用下单语言;切换语言时保留页面参数。再后来又接上邮件,webhook 签发后直接把 key 发到付款邮箱,页面只负责展示。
这种「只有一次」的东西,每一跳都可能丢,不能只保证主路径正确。
2. 测试只能证明它实际走过的那条路
这是我在回测沙箱上反复栽的跟头。
第一次:沙箱的结果通道最初用了容器里的第 4 个文件描述符(fd 3)。后来才发现 Docker 只会把 stdin/stdout/stderr 转发进容器,fd 3 在容器里根本是关闭的。也就是说,容器模式从来没有成功产出过一份报告。所有测试却都是绿的,因为它们要么直接驱动 harness,要么走本地开发用的「不用容器」模式,没有一个真的进过容器。
第二次:Python 策略在生产环境里一次都没有成功过。gVisor 下,超过 64KB 的管道写入会发生短写,剩下的部分会被静默丢掉。Node 的 fs.writeSync 会自己循环写完,Python 裸调 os.write 不会。结果行里每个市场有一条摘要,大约 500 个市场就会超过 64KB。测试里的每个用例却都只有一个市场,结果行 1KB 左右,永远碰不到边界。
这两次的问题一样:测试写得再多,也只能覆盖实际走过的路径。现在加了一个真正启动 Docker 的测试,还有一个包含 700 个市场的用例。这个用例会断言结果行确实超过 64KB,避免测试没触到边界就直接通过。
3. 回测最不能丢的承诺:不能偷看未来
回测报告有没有意义,关键是策略不能拿到它「当时」不该知道的信息。这里漏过两次,而且都不是我原先以为会漏的地方。
一次是市场元数据带着已经结算的结果(outcome)。策略在市场开盘的回调里直接读到了答案,然后「预测」出 900% 的收益。另一次更隐蔽:job 文件挂在容器里的一个路径上。我在静态分析里禁掉了 open、os、pathlib,但白名单里的 pandas 用一句 read_json 就把它读出来了。
现在从结构上解决这个问题:结算前的市场视图一律剥掉 outcome;数据不放在任何策略能打开的路径上,改为从 stdin 逐行喂入,回放循环读一行、处理一行。未来的数据不需要过滤,因为它根本还没进入这个进程。
我也曾把 JavaScript 的静态分析当成安全边界,结果每轮审查都能找到新的绕过方式(constructor.constructor、计算属性名拼接、默认参数捕获……)。后来我不再把分析器当安全边界。它负责提前抓住无意的错误、强制确定性;安全靠容器,以及「计费只用 worker 自己取到的数据来算」。
4. 稳定币收款只能靠精确金额识别
稳定币收款共用一个币安地址,转账里带不了订单号,只能靠金额识别付款人:订单价加上一个 6 位小数的随机尾数。
有一次,一位用户下单时选的是 USDT,实际转的是 USDC。页面明明写着两者等价,后端匹配时却只认下单选择的币种。结果订单过期,钱到了账上,却对不上付款人。修这个问题时才发现,「等价」包含两件事:匹配时不看币种,生成金额时也要保证跨币种唯一。只做前一件事,两个用户就可能拿到同一个金额。
5. 订阅续费的 30 天和自然月
一开始,订阅每次续费固定加 30 天。直到第一笔真实续费发生,我才发现 Stripe 按自然月扣款。遇到 31 天的月份,订阅会在扣款前先过期。那次空档有 25 个小时,刚好被我预留的 2 天宽限期接住。
这个问题也不好修。到期时间是累加值(同一个用户可能叠加了多笔购买),账单周期则是某笔订阅自己的绝对时间,不能直接用后者覆盖前者。最后只在能明确判断「这是一次正常延续」时对齐账期,其余情况保持原来的行为。
流量从哪里来
我原本以为 Google 会是主要来源,所以做了 sitemap、hreflang、结构化数据和几个关键词落地页。结果 Google 收录得很慢,新页面在「已发现 - 尚未编入索引」里卡了很久。
反倒是 ChatGPT 这类 AI 助手带来了真正付钱的用户。区间买断上线后的头 11 笔订单里,有 6 笔来自 chatgpt.com,来自 Google 的是 0。所以我给站点加了一份 llms.txt,用 AI 容易读取的方式写清产品信息。这里又踩了一个坑:AI 复述时会丢掉正文里的限定语,只留下标题。所以标题里不能出现任何需要限定才成立的承诺。
接下来
接下来想做两件事:一是让回测支持更多市场周期,现在只能跑 5 分钟和 15 分钟的市场,Predict.fun 上的小时和日级市场还跑不了。
如果你也在研究预测市场,可以先从公开的样本数据和本地回测开始,不花钱就能跑起来:outcometick.com。