2026年八月27日 · 数据工程

你的标的池是一台时间机器:加密永续合约回测中的幸存者偏差

你的标的池是一台时间机器:加密永续合约回测中的幸存者偏差

你周日晚上把 notebook 发给了我,从那之后我就一直把它开在第二台显示器上。横截面动量,按 30 天美元成交量选出 Binance USDⓈ-M 永续合约前 150 名,每周再平衡,做多前 10% 做空后 10%,回测区间是 2021-01 到 2026-06。Sharpe 2.31,最大回撤 14.2%,成本模型看起来也很扎实:进场和出场都用 taker,按间隔计提资金费率,还有一项会根据你的下单规模与盘口深度变化的滑点成本。难的部分你都做对了。后来你问我,为什么 6 周模拟盘交易下来收益持平,优势是不是衰减了。

它没有衰减。它从来就没出现在回测里。看看第 4 个单元格:

info = requests.get(BASE + "/fapi/v1/exchangeInfo").json()
symbols = [s["symbol"] for s in info["symbols"]
           if s["status"] == "TRADING" and s["quoteAsset"] == "USDT"]

你在 2026 年 6 月调用了这个接口,然后用返回结果定义 2021 年 3 月哪些标的可以交易。按设计,这份列表上的每个标的都存活到了 2026 年 6 月。问题就在这里;光这一点带来的影响,就超过你成本模型其余部分的总和。

2.31 → 0.74Sharpe:幸存者列表与时点数据
约 1/5你 2021 年标的池中的币种如今已不复存在
380 KB每日 exchangeInfo 快照,压缩后大小

这个接口不会告诉你的事

exchangeInfo 没有历史记录。没有 asOf 参数,没有存档,也没有变更日志。它只是一张此时此刻的快照,Binance 从未承诺过其他信息。合约下架时,它的条目会从响应中删除;从 API 的角度看,这个交易对就不再存在。自 2019 年以来,Binance 上市过 600 多个 USDⓈ-M 永续合约,也下架了 100 多个。BTS、COCOS、TOMO、RAY、FTT、SC,还有一长串 2021 年山寨币季的合约:它们曾有过辉煌的一个季度,之后交易逐渐冷清,最终被交易所下架。

想想看,30 天动量筛选最青睐哪些币。不是 BTC。它喜欢的是刚刚因为上币炒作和 Twitter 热度翻了两倍的币。这类币和最终下架的币高度重合,而你的标的池筛选恰好把这部分重合项删掉了。

我用我们的快照存档、时点标的池、相同信号和相同成本模型重新跑了你的策略,Sharpe 只有 0.74,回撤达到 31%。两者差距中大约五分之二来自你根本不可能持有的已下架标的。另有四分之一来自一个镜像问题;它更隐蔽,我猜你会更不喜欢。

时间机器的另一端:当时还不存在的标的

你的特征是 90 天收益率。滚动窗口设成了 min_periods=20,因为你当初为了避免预热期吞掉样本的第一个季度,把它设好后就再也没回头看过。于是,一个 21 天前才上线的合约,会根据上线后 3 周的价格走势算出动量分数;只要上币后走势不错,它往往就会排进前 10%,然后进入你的组合。

这部分勉强说得过去。真正不对劲的是:你的数据提供商给其中一些标的回填了该永续合约出现之前的现货或指数数据,所以少数合约的 K 线历史甚至早于它们自己的 onboardDate。大约花 1 分钟就能查出来。把价格表和上线日期关联起来,统计上线前的记录数。你的数据里有 41 个标的存在上市前 K 线,其中一个在 2023 年夏天,为权益曲线贡献了单周 6% 的涨幅;这笔收益来自一个还要再过 9 天才会出现的合约。

交易代码字符串不是标识符。它只是交易所借给你的标签,有时同一个标签还会用两次。

这就说到了更名。MATICUSDT 变成了 POLUSDT。FTMUSDT 经过 1:1 转换后变成 SUSDT。2022 年 5 月的 LUNA 事件留下了 LUNCUSDT,后来又出现了全新的 LUNAUSDT;两者代码相似,前者却几乎一文不值。如果你的加载器以交易代码字符串作为键,再把找到的文件拼接起来,数据里至少会有一条序列存在非价格变动造成的不连续;你的动量特征会把这段断层读成横截面里最强的信号。

其他所有悄悄变化的字段

一旦你接受标的列表会随时间变化,同样的道理就适用于响应中的其他所有字段。而你现在用的全是今天的值。

字段变化方式造成的问题
statusTRADING → SETTLING → 下架幸存者偏差;仿佛在从未成交过的收盘价平仓
onboardDate每周都有新标的上市;已下架标的则查不到日期在合约出现之前就交易它
tickSize / stepSize随价格水平变化而调整订单取整错误,以及本会被拒绝的限价单
minNotional在流动性不足的盘口上逐步提高实盘路由器会拒绝的小额头寸
fundingIntervalHours多年都是 8h,后来许多标的改为 4h 或 1h你持仓的那些山寨币,资金费率收益或成本恰好会差 2–3 倍
杠杆档位档位和维持保证金都经过调整强平建模和保证金容量

就你的情况而言,资金费率的问题影响最大。你的计提循环假设整个历史期间每天支付 3 次。相当一部分山寨币组合改成了 4 小时结算,而高资金费率标的的空头头寸贡献了大量模拟盈亏。这里差的可不是四舍五入。你算错了一个倍数。

下架本身就是一个事件,而你没有对它建模

在按时点数据重跑时,我给策略安排了一个相当宽松的退出条件。真实的下架流程有迹可循:先发布公告,通常提前 7 到 14 天;接着进入只减仓时段;最后按标记价格强制结算。公告是公开信息,你可以据此采取行动,因此合理的模拟应该在公告当天收盘时退出。但典型山寨币在下架公告前一周,价格已经比之前低了 10-20%;此时盘口流动性很差,你的滑点模型也得知道自己面对的是另一种市场状态。如果你的成交引擎让你毫无冲击成本地按结算价成交,就等于悄悄把濒死资产按公允价值交易了。

如果你从来没存档过 exchangeInfo,也还来得及补救。公开数据集 data.binance.vision/data/futures/um/monthly/klines/ 至今还保留着已下架标的的目录,远在 API 忘记它们之后。列出这些目录,再找出每个标的最早和最晚的月度文件,就能在不依赖任何数据供应商的情况下,大致推断出它的上市和下架时间窗口。这是重建结果,不是原始记录,也无法还原最小价格变动单位或资金费率结算间隔。但它能告诉你每个标的何时存在;这就解决了你周一要做的事情中的 80%。

重新碰信号之前,我建议你先构建这些东西

  1. 写个 cron,每天从你研究涉及的每个交易所拉取 exchangeInfo,并按日期存入对象存储。压缩后不到半兆。存 10 年在 S3 上几乎不算成本,却能为你带来一种以后无法购买的研究能力。
  2. 根据这些快照建立资产主表:每个 (交易所, 标的, valid_from, valid_to) 组合一行,包含完整字段集。对比相邻快照生成记录,并把任何字段变化都视为新的一行。
  3. 构建一个必须传入时间戳参数的标的池函数。universe(ts),绝不能用 universe()。把默认值设成不可能使用,这样谁也不会意外选到幸存者列表,包括凌晨 1 点的未来的你。
  4. 在数据加载器中加入上市前断言:任何 K 线都不能早于 onboard_ts 减去 1 天。直接让任务失败,不要只发警告。
  5. 建立稳定的内部工具 ID,让它在更名后保持不变,并将交易代码降为属性。把 POL 和 MATIC 映射到同一个 ID,并标记面额重定,使连续性逻辑可以拒绝跨越重定的序列。

把这些做完,再重跑一次。我估计结果会接近我的 0.74。接下来值得追问的是:这个包含已死亡标的的 0.74,到底有没有值得拿去做模拟盘交易的东西。也许有。永续合约的横截面动量并非毫无价值;修正标的池后剩下的优势,有一部分确实来自空头一侧的持有收益。你还会发现,模拟盘结果和回测终于开始吻合,因为模拟盘一直用的都是时点标的池。它本来也别无选择。

附言:这并非加密货币特有的问题,只是在这里格外明显。自从 CRSP 开始提供数据以来,股票研究者就一直在处理退市收益和重复使用的代码;预测市场则把问题推到了极致:每份合约都会按设计到期,所以标的池里只有新上市和已到期的合约。如果你以后要把这个筛选策略移植到 Kalshi,先建立资产主表。那里的历史数据只有这一种形态。

幸存者偏差时点数据加密货币期货回测数据工程
← 全部文章