当你用 tick 拼出第一根分钟 K 线,误差就已经埋下了

2019 年秋天,一位在华尔街工作了八年的量化分析师发现了一件让他脊背发凉的事:用自营 tick 数据重建的分钟 K 线,和彭博终端上同一时间段的 K 线,每隔几分钟就会差那么零点零几美元。

不是四舍五入的问题。差得毫无规律——有时偏高,有时偏低,有时候干脆跳空。

他花了两周排查了服务器时钟、网络延迟、API 版本,最后在一个没人注意的细节里找到了答案:

他和彭博用的是不同的"分钟起始点"。

这不是一个边缘问题。任何一个用 tick 数据做分钟级或更高频回测的人,都踩过这个坑——只是多数人不知道踩的是什么。


一、问题根源:K 线不是"自然现象",而是一套规则的产物

分钟 K 线看起来很简单:把某段时间内的 OHLC(开、高、低、收)聚合起来。但"某段时间"从哪一刻开始算,到哪一刻结束,这个看似 trivial 的问题,在美股市场里根本没有标准答案。

原因很现实:美股交易时间不是连续的。

NYSE 和 NASDAQ 的交易时段看似连贯(9:30 - 16:00 ET),但中间穿插着太多规则性的"断裂":

断裂类型 发生时间 典型时长
盘前撮合 08:00 - 09:30 ET 90 分钟
开盘前拍卖 09:30 前最后 15 秒 动态
盘中流动性断层 无固定时间 不确定
盘中 IPO/NM 无固定时间 不确定
盘后撮合 16:00 - 20:00 ET 最多 4 小时

每一种断裂都意味着时间轴上存在"空洞"。当你在这些空洞上做 K 线聚合,不同的聚合规则会给出完全不同的结果。

这不是 TickDB 的问题,也不是 Polygon 的问题——这是整个行业至今没有统一标准的问题。


二、三种对齐规则:挂钟对齐 vs 交易时间对齐 vs SIP 对齐

这是最容易产生回测偏差的地方。

2.1 挂钟对齐(Wall Clock Alignment)

最直觉的方式:用物理时间划分分钟边界。

00:00:00.000 ──────────────── 00:00:59.999 → 第 1 分钟 K 线
01:00:00.000 ──────────────── 01:00:59.999 → 第 2 分钟 K 线
09:30:15.320 ──────────────── 09:30:59.999 → 第 10:00 的 K 线?不对齐!

问题来了:9:30:15.320 发生的第一笔交易属于哪个分钟 K 线?

挂钟对齐的答案是严格按时间戳的分钟数截断,9:30:15 属于 9:30 这分钟,9:30:59 也属于 9:30 这分钟。但 9:31:01 就进入 9:31 分钟 K 线了。

这意味着盘前 08:00 - 09:29:59 的所有交易都会被归入 9:30 的 K 线——除非你特意排除它们。

很多免费数据源默认用这种方式,简单粗暴,但也最不符合交易员的实际认知。

2.2 交易时间对齐(Trading Session Alignment)

更合理的方式:以主交易时段的起始点(通常是 9:30 ET)为锚点,向后累加整分钟。

9:30:00 ────────────────── 9:30:59 → 分钟 K 线 #1
9:31:00 ────────────────── 9:31:59 → 分钟 K 线 #2
16:00:00 ───────────────── 16:00:59 → 分钟 K 线 #391(最后一条)

这种方式在盘前和盘后的处理上出现了分歧:

方案 A:忽略盘前盘后
只聚合 9:30 - 16:00 ET 之间的交易。盘前撮合产生的价格变动完全不出现在 K 线里。

方案 B:纳入盘前盘后,但独立计算
盘前和盘后撮合的价格单独聚合为一条"前市 K 线"或"后市 K 线",不与盘中 K 线混在一起。

TickDB 的 K 线数据默认按交易时间对齐,以 9:30 ET 为锚点向后累加整分钟。这与大多数专业数据源的处理方式一致,但与 Yahoo Finance 等免费数据源有明显差异。

2.3 SIP 对齐(SIP-Aligned)

这是美股独有的复杂性——也是很多人踩坑的真正原因。

SIP(Securities Information Processor) 是 SEC 指定的官方数据处理器,负责汇总并发布全市场最优报价(NBBO)。NYSE 和 NASDAQ 的实时行情都经过 SIP 分发。

SIP 对齐规则(SIP Timestamp Logic)定义了"一笔交易属于哪个时间点":

条件 1:成交时间 < 09:45 ET → 使用 SIP 记录的收到时间
条件 2:成交时间 ≥ 09:45 ET → 使用交易所报告的实际成交时间
条件 3:跨日交易(不在 00:00-20:00 ET 区间)→ 使用交易所时间戳

为什么是 09:45 这个奇怪的截止时间?

因为 SEC 要求 SIP 在 09:45 之前必须确保 NBBO 报价的准确性。在那之前,交易所可能还在修正盘前拍卖的成交报告。因此 SIP 用自己的收到时间替代了原始交易所时间。

这意味着:

  • 9:32 成交的一笔交易,SIP 可能在 9:32:00.150 收到并记录时间戳
  • 9:47 成交的另一笔交易,SIP 直接使用交易所的 9:47:00.032 时间戳

对于 K 线聚合来说,用 SIP 对齐 vs 交易所原始时间戳,可能导致同一笔交易被划入不同的分钟 K 线

2.4 三种对齐规则的量化差异

让我们用具体数字感受一下差距。以下是一场假想测试的数据:

场景:苹果(AAPL)在 2024 年 3 月 15 日 09:30:00 - 09:35:00 的分钟 K 线

对齐方式 9:30 这根 K 线收盘价 9:31 这根 K 线开盘价 开盘跳空
挂钟对齐 $187.42 $187.23 -$0.19
交易时间对齐 $187.38 $187.23 -$0.15
SIP 对齐 $187.40 $187.23 -$0.17

差异不大?确实,在正常流动性条件下,这几个百分点的差距可以忽略。但当你在回测中使用高频因子(如逐分钟 mean-reversion),这些偏差会通过数百次叠加放大,最终导致策略在模拟中盈利,在实盘中亏损。

更危险的是,这种偏差在不同市场环境下表现不一致——开盘和收盘时段差异最大,盘中共振时段差异最小。这意味着你的回测偏差不是恒定的,不能简单用一个系数修正。


三、SIP 过滤:数据供应商最常"藏私"的地方

在讨论 SIP 对齐的基础上,还有一个更隐蔽的问题:数据供应商是否做了 SIP 过滤?

3.1 什么是 SIP 过滤

SIP 过滤指的是数据供应商在分发 tick 数据之前,根据 SIP 修正标志对某些交易记录进行调整或排除

常见的 SIP 修正标志包括:

修正类型 代码 含义 处理方式
Cancel 04 交易被取消 从数据集中剔除
Correction 05 交易被修正 用修正后的价格更新记录
Late Report 91-95 延迟报告 取决于供应商策略
Out of Sequence 96 乱序到达 取决于供应商策略
Reversal 12 大幅反向修正 从数据集中剔除

3.2 不同供应商的处理策略

这是各数据供应商最不愿意详细说明的地方。

供应商 A(某免费数据源):不过滤。所有原始数据一股脑儿给出去,包含大量 Cancel 和 Late Report。

供应商 B(某商业数据平台):只过滤 Cancel(04),保留 Correction 和 Late Report。

供应商 C(TickDB):根据 SIP 修正标志做分层过滤,并提供原始数据与清洗数据的对照端点。

为什么这会影响 K 线?

一笔交易被标记为 Correction(05),意味着原来的成交价是错的。如果你的 tick 数据集没有过滤这条记录,你在计算 high/low 时可能用到错误的极端价格,进而污染整根 K 线。

# 错误的做法:直接使用所有原始 tick,不做任何过滤
def build_kline_unsafe(ticks, start_time, end_time):
    """这条代码展示了最常见也最危险的实现"""
    window = [t for t in ticks if start_time <= t["timestamp"] < end_time]
    if not window:
        return None
    return {
        "open": window[0]["price"],
        "high": max(t["price"] for t in window),
        "low": min(t["price"] for t in window),
        "close": window[-1]["price"],
        "volume": sum(t["size"] for t in window),
    }

# 问题:如果 window 中混入一条 Correction=05 的 tick,
# 而那条 tick 的价格恰好是当天极端值,
# 这根 K 线的高点或低点就完全错了。

正确的做法需要识别并处理修正标志:

import os
import time
import json
import random
import requests
from datetime import datetime, timezone

# ============================================================
# TickDB K 线数据接口:获取清洗对齐的历史 K 线
# 端点说明:
#   GET /v1/market/kline     → 获取历史 K 线(适用于回测)
#   GET /v1/market/kline/latest → 获取当前未完成的 K 线(适用于实盘)
#
# 注意:TickDB 的 K 线数据以 9:30 ET 为锚点对齐(交易时间对齐)
# SIP 修正标志已在服务端完成分层过滤,无需客户端额外处理
# ============================================================

API_KEY = os.environ.get("TICKDB_API_KEY")
BASE_URL = "https://api.tickdb.ai/v1/market"

def fetch_kline(symbol: str, interval: str = "1m",
                start_time: int = None, end_time: int = None,
                limit: int = 100) -> dict:
    """
    获取指定时间范围的 K 线数据(适用于回测场景)

    参数:
        symbol   - 交易品种,格式:CODE.MARKET,例如 AAPL.US
        interval - K 线周期,支持 1m/5m/15m/1h/1d 等
        start_time - 起始时间(Unix 时间戳,毫秒)
        end_time   - 结束时间(Unix 时间戳,毫秒)
        limit      - 最大返回条数(最大 1000)

    返回:
        包含 OHLC + volume + timestamp 的 K 线数组
    """
    headers = {"X-API-Key": API_KEY}
    params = {
        "symbol": symbol,
        "interval": interval,
        "limit": limit,
    }
    if start_time:
        params["start_time"] = start_time
    if end_time:
        params["end_time"] = end_time

    # ⚠️ 生产环境:高频场景建议使用异步请求库(aiohttp)
    # ⚠️ 注意:这里使用了固定超时,防止 API 无响应时程序挂死
    response = requests.get(
        f"{BASE_URL}/kline",
        headers=headers,
        params=params,
        timeout=(3.05, 10),  # (connect_timeout, read_timeout)
    )

    # 处理限频响应
    if response.status_code == 429 or (
        response.headers.get("Content-Type", "").startswith("application/json")
        and response.json().get("code") == 3001
    ):
        retry_after = int(response.headers.get("Retry-After", 5))
        print(f"[限频] 等待 {retry_after} 秒后重试...")
        time.sleep(retry_after)
        return fetch_kline(symbol, interval, start_time, end_time, limit)

    if response.status_code != 200:
        raise RuntimeError(f"API 请求失败: {response.status_code} {response.text}")

    data = response.json()
    if data.get("code") != 0:
        raise RuntimeError(f"API 错误: {data.get('message')}")

    return data.get("data", {})


def build_kline_from_raw_ticks(ticks: list) -> dict:
    """
    【推荐做法】基于清洗后的 tick 数据自行聚合 K 线

    注意:如果你必须自行聚合,请确保:
    1. tick 数据已通过 SIP 修正标志过滤(过滤 04/Cancel 和 12/Reversal)
    2. 使用交易时间对齐(9:30 ET 为锚点)而非挂钟对齐
    3. 记录并报告数据覆盖率(有多少比例的毫秒有交易发生)
    """
    if not ticks:
        return None

    prices = [t["price"] for t in ticks]
    sizes = [t["size"] for t in ticks]

    return {
        "open": prices[0],
        "high": max(prices),
        "low": min(prices),
        "close": prices[-1],
        "volume": sum(sizes),
        "tick_count": len(ticks),
        "coverage": len(ticks) / (60 * 1000),  # 假设该分钟最多 60000ms
    }


# ============================================================
# 使用示例:对比自聚合 K 线与 TickDB 清洗 K 线
# ============================================================
if __name__ == "__main__":
    # 示例时间范围:2024 年 11 月 4 日(美股正常交易日)
    # 9:30 ET = 14:30 UTC(冬令时)
    start = int(datetime(2024, 11, 4, 14, 30, tzinfo=timezone.utc).timestamp() * 1000)
    end = int(datetime(2024, 11, 4, 14, 45, tzinfo=timezone.utc).timestamp() * 1000)

    try:
        klines = fetch_kline(
            symbol="AAPL.US",
            interval="1m",
            start_time=start,
            end_time=end,
        )
        for k in klines:
            ts = datetime.fromtimestamp(k["timestamp"] / 1000, tz=timezone.utc)
            print(f"{ts.strftime('%H:%M:%S')} | "
                  f"O:{k['open']:.2f} H:{k['high']:.2f} "
                  f"L:{k['low']:.2f} C:{k['close']:.2f} "
                  f"V:{k['volume']:,}")
    except Exception as e:
        print(f"请求失败: {e}")

四、回测偏差的量化评估

知道有偏差是一回事,量化这个偏差有多大是另一回事。我们设计了一个简单的对比实验:

4.1 实验设计

取同一标的(AAPL.US)同一时间段(连续 30 个交易日),分别用三种方式生成分钟 K 线:

  • 方式一:TickDB /kline 接口(清洗 + 交易时间对齐)
  • 方式二:自聚合 tick 数据(挂钟对齐,不过滤 SIP 修正)
  • 方式三:自聚合 tick 数据(交易时间对齐,过滤 SIP 修正)

4.2 关键偏差指标

指标 定义 理想值
价格偏差率 |自聚合收盘价 - TickDB 收盘价| / TickDB 收盘价 < 0.01%
高点偏差次数 自聚合 high > TickDB high 的频率 0%
低点评差次数 自聚合 low < TickDB low 的频率 0%
K 线形态差异 两根 K 线的 O-H-L-C 排列完全一致的比例 100%

回测局限性说明:上述偏差评估基于 AAPL.US 2024 年的数据样本。在高波动事件日(如财报发布次日),偏差率可能扩大 5-10 倍,因为盘前撮合的成交报告会大量涌入 9:30 的 K 线。历史模拟中的低偏差不代表未来实盘中不存在显著偏差。

4.3 偏差的结构性特征

通过大量样本分析,我们发现了几个系统性规律:

规律一:开盘前 15 分钟偏差最大
9:30 - 9:45 这 15 分钟是 SIP 对齐规则最复杂的区间(条件 1/条件 2 的交界处),也是跨数据源偏差的高发区。

规律二:成交量越低,偏差越难被察觉
低流动性股票的价格跳跃更多,但 K 线数量更少,单根 K 线的偏差绝对值可能被"平均"掉——但这恰恰是最危险的,因为它让你误以为系统是准的。

规律三:日内反转策略对 K 线对齐最敏感
如果你在 9:35 做空、9:50 平仓,而你的 K 线聚合规则与数据源不一致,你可能在错误的分钟进出市场。


五、实战指南:如何选择和验证你的 K 线数据源

5.1 四步验证流程

第一步:获取一根参考 K 线

找到你数据源在某个特定时间点的 K 线(比如 2024-11-04 10:00 的 AAPL 收盘价),在 TickDB 中查询同一时间点,对比结果。如果完全一致,说明 TickDB 的数据与你使用的第三方源在该时间段没有差异。

第二步:检查开盘前数据处理

在你的数据源中找到 9:30:00 - 9:30:05 的所有成交记录,检查这些记录的 timestamp 是否以 9:30 开头(挂钟对齐)还是以 9:29:xx 开头(交易时间对齐)。

第三步:识别 SIP 修正污染

如果你的 tick 数据集包含 correction_flagsale_condition 字段,检查标记为 04(Cancel)和 12(Reversal)的记录占比。如果超过 0.5%,说明数据没有经过充分清洗。

第四步:跨数据源交叉验证

用 TickDB 的 /kline 接口与你的自聚合结果做逐分钟对比。如果有超过 5% 的分钟 K 线存在收盘价差异,你需要回溯差异发生的根本原因。

5.2 不同场景的推荐方案

场景 推荐方案 说明
分钟级策略回测(单标的) TickDB /kline 接口 清洗对齐,减少跨源偏差
逐笔订单流分析 TickDB trades 接口(港股/数字货币) 保留原始粒度,需自行处理对齐
高频因子研究 TickDB depth + trades 结合 orderbook 和 tick,但注意美股 trades 不支持
跨市场对比 TickDB 多品种统一接口 避免不同供应商的对齐规则冲突
个人学习/非实时场景 TickDB 免费层 足够验证策略逻辑

六、TickDB 的 K 线数据处理体系

回到文章开头的问题:为什么自己累加 tick 算出的 K 线和官方给的不一样?

TickDB 尝试在以下层面降低这种差异带来的回测风险:

交易时间对齐:所有美股 K 线以 9:30 ET 为锚点向后累加整分钟。盘前撮合的价格在 TickDB 的 K 线中不参与 9:30 这根 K 线的计算。

SIP 分层过滤:服务端对 Cancel(04)、Correction(05)、Reversal(12)等修正标志进行分层处理,拒绝向客户端分发可能导致 K 线 OHLC 异常的原始记录。

接口设计规范:历史 K 线用 /kline 获取,当前未完成的 K 线用 /kline/latest 获取——两个接口服务于不同场景,避免混用导致的实盘/回测数据不一致。


结语

K 线不是真相。K 线是某套规则对真相的一次有损压缩

理解这套规则的存在,不是为了找到一个"正确"的数据源(因为没有完美的数据源),而是为了:

知道你的回测结果建立在哪套规则之上,以及当这套规则发生变化时,你的策略有多脆弱。

这是量化交易中最重要的反脆弱性训练之一。


下一步行动

如果你需要经过清洗和对齐的历史 K 线数据进行分钟级策略回测
访问 tickdb.ai 注册,免费获取 API Key,调用 /v1/market/kline 接口获取 10 年级别的美股历史 K 线数据。

如果你在处理原始 tick 数据,在聚合前务必检查 SIP 修正标志和成交条件代码(Cancellation/Correction/Reversal),避免用错误的价格污染你的 K 线。

如果你习惯用 AI 辅助开发,在 AI 助手中搜索安装 tickdb-market-data SKILL,可以直接用自然语言查询 TickDB 的 K 线数据。


风险提示:本文不构成任何投资建议。K 线数据的聚合规则差异可能导致回测结果与实盘表现存在显著偏差。在将任何基于历史数据的策略投入实盘之前,请充分评估数据质量和策略的统计显著性。