这是一根 K 线。Binance USDⓈ-M 永续合约,BTCUSDT,起始于 UTC 时间 13:46:00。它从 klines endpoint 返回时只是一个包含十二个值的裸数组,也是零售及相关量化研究中最常见的输入单元。我们生成的几乎每个策略,都会接触到类似这样的数据。
所以,让我们按索引逐个拆开,看看回测究竟会把多少东西弄错。
| 索引 | 值 | 名称 |
|---|---|---|
| 0 | 1773495960000 | 开盘时间(毫秒) |
| 1 | "84120.50" | 开盘价 |
| 2 | "84177.90" | 最高价 |
| 3 | "84098.10" | 最低价 |
| 4 | "84163.40" | 收盘价 |
| 5 | "38.417" | 基础资产成交量(BTC) |
| 6 | 1773496019999 | 收盘时间(毫秒) |
| 7 | "3232108.94" | 计价资产成交量(USDT) |
| 8 | 1204 | 成交笔数 |
| 9 | "21.883" | 主动买入基础资产成交量 |
| 10 | "1841203.55" | 主动买入计价资产成交量 |
| 11 | "0" | 忽略 |
[0] 和 [6]:这是哪一分钟,谁的时钟说了算
开盘时间是 1773495960000,收盘时间是 1773496019999。注意第二个时间戳:它以 19999 结尾,比下一根 K 线的开盘时间少 1 毫秒。这个时间窗口是左闭右开,而交易所明确告诉了你这一点。我追查过的一半数据问题,都始于有人把两个端点都当成闭区间,导致边界处的成交被重复计算;或者重采样对不齐,让每根 5 分钟 K 线从相邻 K 线借走 1 毫秒。
这些时间戳记录的是交易所撮合引擎时间。不是你的时钟,不是供应商的数据接入时钟,也不是资金费率快照所使用的同一时钟。当你把 K 线序列和从另一个 endpoint 拉取的资金费率序列或未平仓量序列连接起来时,你实际上是在连接两个数据源。它们大多数时候只能保证彼此相差大约 1 秒。对于 1 分钟策略,这意味着每一行连接数据都有 1.7% 的时间误差。对于任何亚分钟级策略,这都是致命的。
而且,这根 K 线的时间戳标在开盘时刻。直到 13:46:59.999,你才可能知道其中的信息。所有索引约定问题——我要不要向前错一根、应该使用收盘时间戳还是开盘时间戳作为事件时间——归根结底都是同一个问题:只是把前视问题换了件衣服。
[1] "84120.50":你无法交易到的开盘价
开盘价是这个时间窗口内第一笔成交的价格。那是其他两个人之间已经完成的交易。等这根 K 线作为一行出现在你的 dataframe 中时,这个价格已经是 60 秒之前的价格了。
上一根 K 线收于 84109.80,因此相邻两根 1 分钟 K 线之间的缺口里,已经发生了 $10.70 的价格变动。这是 1.3 个基点,在图表上几乎看不出来,大约相当于 taker 手续费的四分之一。没关系。但去看看美国 CPI 数据公布时某个山寨币永续合约的同一序列,那些 K 线间缺口会达到 15 到 40 个基点。一个在 K 线收盘时发出信号、在下一根 K 线开盘时成交的回测,悄悄假设了缺口为零;而它恰恰会在缺口最重要的时候把它当成零。
[2] 和 [3]:"84177.90" 最高价,"84098.10" 最低价
大多数成交引擎都会在这里走向毁灭,所以这一节会比较长。
最高价和最低价是曾被触及的价格极值。它们不包含成交量,也不包含持续时间。从同一分钟的原始成交明细来看,所有不高于 84100.00 的成交,在 1.4 秒的时间窗口内分九笔完成,总量为 0.62 BTC。因此,回测中设在 84100 的止损会“成交”——如果你的仓位是 3 BTC,那么价格下行过程中你吃掉了整个可见盘口,剩余数量则会在 84105–84130 的回撤过程中某处成交。K 线告诉你最低价是 84098.10,但它没有告诉你,在那里成交的名义金额只有 $52,000。
另一个方向更糟,因为它会让你的结果看起来更漂亮。假设你在 84175 挂了一张限价卖单。K 线最高价是 84177.90,因此一个天真的引擎会以 84175 为你成交,并记入 maker 返佣,而不是 taker 成本。这个成交是否真的发生,取决于你在该价位的排队位置;而 K 线无法知道这一点,你可能也从未记录过。触及不等于成交。
我们最终在成交引擎中确定的规则是:挂单限价单只有在 K 线穿过该价位时才会成交,而不只是触及该价位。在恰好触及极值的情况下,必须有来自成交明细的价位成交量证据,否则拒绝成交。这一规则让典型均值回归组合中的成交减少了约 6%,并让某个候选策略的回测 Sharpe 从 1.9 降到了 1.1。那个候选策略从来都不是真实的;成交规则只是第一个诚实到足以承认这一点的东西。
另一个相关陷阱是日内路径。如果一根 K 线的波动区间同时覆盖了你的止损和止盈,OHLCV 无法告诉你哪个先发生。每个引擎都必须选择一种约定。我们的约定始终是止损优先:这种做法偏保守,偶尔会错,但绝不会凭空制造假盈利。如果你的引擎假设止盈优先,那么大波动 K 线会从这种歧义中制造利润;而大波动 K 线恰恰是主导你 PnL 分布的那些 K 线。
[4] "84163.40":这根 K 线上最不稳健的数字
收盘价是这个时间窗口内最后一笔成交的价格。仅此而已。它可能是某个机器人在 13:46:59.8 为了完成仓位而进行的一笔 0.002 BTC 的零碎成交。这个在结构上完全任意的单笔成交,却是大多数研究管线用来计算每个信号、标记每个仓位、评估每次退出的依据。
在 BTC 永续合约上,这几乎无关紧要;但在 UTC 时间 04:00 的流动性稀薄山寨币永续合约上,它的重要性会大得多。同一分钟内两个交易所的收盘价之差,可能超过你每笔交易的全部 edge。当一个策略的 PnL 特别依赖收盘价时,我们会改用交易所标记价格重新运行;标记价格源自指数,更难被轻易操纵。如果结果出现分歧,那说明策略交易的是数据伪影。
[5] 和 [7]:"38.417" 与 "3232108.94",成交量的单位究竟是什么
基础资产成交量是 BTC;计价资产成交量是 USDT。这里两者都有,这已经算是交易所提供的一项便利,并非所有交易所都会提供。之所以需要关注,是因为你需要跨交易所聚合数据。币本位合约以 $100 名义价值的合约数报价。一些股票数据源会报告 round lots。预测市场交易所会报告份额数量,而一份份额代表一个以美元计价的二元结果。没有先统一成单一名义价值单位,就把不同资产 universe 中的“成交量”相加,得到的流动性排名纯粹是胡说,而且会以一种看起来稳定、能够经得起审查的方式胡说下去。
在数据接入时,把所有数据统一成计价资产名义价值。原始字段也要保存,但永远不要让策略直接看到它。
[8] 1204:应该用来设定冲击模型的字段
基础资产成交量除以成交笔数,得到平均每笔成交 0.032 BTC,约 $2,700。如果你的候选策略想一次性建仓 $250,000,那么它要求的规模大约是该分钟典型交易规模的 92 倍。决定平方根冲击项的应该是这个数字,而不是什么通用的 5-bps 滑点常数。我们会为每根 K 线计算参与率,并将其作为核心字段;如果某个策略的入场规模中位数超过 K 线名义价值的几个百分点,就拒绝它,因为这之后的一切都是虚构。
成交笔数还可以低成本地标记异常分钟。成交量正常,成交笔数却降到 11?有人做了一笔大宗交易。成交量正常,成交笔数却达到 9,000?那是清算级联被拆成小块不断吃掉。
[9] 和 [10]:"21.883",所有人都会丢弃的字段
主动买入基础资产成交量。在这根 K 线的 38.417 BTC 中,有 21.883 BTC 由买方发起,因此有符号成交量差为 +5.349 BTC,主动方分布为买方 57%、卖方 43%。交易所免费把订单流不平衡交给了你,就放在一个大多数人从未读取的字段里,因为 pandas 没有替他们给这一列命名。
我不是说它单独就能预测收益;天真的 delta 策略是向手续费账本捐款的更可靠方式之一。但它确实是与价格不同的测量指标,而且就在你本来已经发出的同一个请求中提供给你。它还能让你区分:一轮上涨是被买出来的,还是因为卖方撤开而发生的。这两种上涨在 OHLC 上看起来完全一样,但十分钟后的表现不同。我们的研究代理认为,在某个交易所发布主动方分布的情况下,一个假设如果忽略它,就是把证据留在桌面上。
[11] "0":忽略——以及其他所有不在这里的东西
索引 11 是一个已弃用的字段,永久为零。更有意思的是,这根 K 线没有包含的东西:没有 bid,没有 ask,没有 spread,没有盘口深度,没有资金费率,没有未平仓量,没有清算数据,没有标记价格,没有指数价格。更关键的是,它没有办法告诉你你的订单会成为 maker 还是 taker,而这决定了你在这个交易所是支付 0.045%,还是赚取 0.01%。
所以,任何只建立在 klines 之上的手续费模型,都是给一个数字穿上衣服的假设。我们的解决办法是强制每个策略提前声明其执行风格,然后对任何无法证明其他情况的交易都收取 taker 费用。
那根从未出现的 K 线
最后一点,也是非主流品种最容易造成严重后果的一点。一分钟内没有任何成交,就根本不会产生 K 线。供应商和库通常会对它做前向填充:open = high = low = close = 前一根收盘价,volume 为 0。你的指标会正常计算。你的策略会看到一行有效数据,并可能在全世界没有任何人交易该品种的那一分钟生成信号。
在我们接入的一个中等市值永续合约上,12 个月窗口内有 4.1% 的 1 分钟 K 线没有任何成交。该品种上的一个均值回归候选策略,有 38% 的入场发生在合成 K 线上,因为平坦的合成价格对任何衡量价格偏离移动平均线的东西都极具诱惑力。它的回测表现漂亮极了。它交易的是数据中的空白。
所以,现在接入层会为每根 K 线携带一个synthetic布尔值,并在每次重采样时一路传递;验证闯关会淘汰任何交易集中发生在这类 K 线上的策略。这是一个廉价的字段,却比我们写过的任何指标都淘汰了更多候选策略。
十二个值。其中四个经常被误读,两个经常被丢弃,还有一整类信息根本不存在于这一行中,却被引擎自行想象出来。在下一次回测之前,从你自己的数据存储中取出一根原始 K 线,对照你的成交逻辑对它的假设,把每个字段大声读出来。这只需要 20 分钟,而我从没见过有人做完之后发现什么问题都没有。
← 全部文章