休市提醒日股将于 2026-11-03 休市(文化日),距今天还有 5 天。全部公告
文章

从 WebSocket tick 流自建 K 线聚合器:分桶时钟、迟到数据与 REST 对账

REST 历史 K 线与实时 tick 流之间缺一层语义:把 tick 按时间窗口合成为 bar 并保证口径一致。本文从零写一个可离线运行的聚合器,处理分桶边界、空窗口与迟到 tick,再用差异分类对账器与 REST 历史 K 线做上线验收,附可运行代码、字段映射表与三类差异的根因对照。

自建 K 线聚合器(K-line aggregator)补的是 REST 与 WebSocket 之间那一层语义:REST 历史 K 线接口给你已定型的 bar,实时流给你毫秒级 tick,而「把 tick 按时间窗口合成为 bar,并保证与历史口径一致」这件事,需要你自己实现。TIQEX 的 REST 基址是 https://api.tiqex.io/api/v1/market/*,流式端点是 wss://api.tiqex.io/api/v1/market/stream,两者共用 X-API-Key 鉴权与 succ / data / msg / code 信封。本文从零写一个可离线运行的聚合器,再拿它对 REST 返回的历史 bar 做对账。

边界说明:OHLCV 各字段的含义属于「是什么」,已在 OHLCV 字段术语页 讲清;本文只讲「怎么造」,不重复字段定义。行情录制器(断线续录与缺口检测)解决的是持久化与回放,本文解决的是时间分桶与口径对账,两者可组合但互不替代。

什么时候必须自建聚合器

不是所有场景都需要自己合成 bar。以下三种情况,REST 历史 K 线无法直接满足。

其一,回补窗口受单次上限约束。K 线接口的 limit 默认 100、上限 1000。用 1 分钟粒度回补一个交易日的 1,440 根 bar,单次请求装不下,必须分页拼接;而每次请求都计入日配额与每分钟请求数,免费版档位的 RPM 为 30。高频回补会迅速消耗配额。

其二,实时指标需要未收盘 bar。历史接口返回的是已定型窗口。均线、VWAP 或阈值告警如果等到窗口结束再算,就丢失了整个窗口内的响应时间。聚合器让每收到一个 tick 就更新当前 bar。

其三,跨市场口径需要统一。各市场的 K 线路由参数并不一致:美股 K 线的 interval 取值是 1T / 5T / 30T / 1H / 2H / 4H / 1D / 1W / 1M(T 表示分钟),加密货币 K 线则沿用 Binance 风格的 1m / 1h;bar 内字段也不同。自建聚合器可以对外只暴露一套内部 schema。

分桶时钟:把 tick 时间映射到 bar 边界

流式帧里的 tick.at 是 RFC3339 时间字符串,第一步是把它归一化到 UTC。分桶必须使用「左闭右开」区间 [start, start + interval):某一刻恰好落在边界上时,它属于新窗口,不属于旧窗口。实现上建议用 Unix epoch 整除,而不是用本地时区做加减——后者会在夏令时切换日产生重复或缺失的窗口。

另一个容易做错的地方是空窗口。若某个窗口内没有收到任何 tick,正确的输出是「没有 bar」,而不是「价格为 0 的 bar」。把空窗口写成 0 会让下游的均线、涨跌幅全部失真。术语页已提醒过:跨市场对比时,空 bar 往往代表节假日而非停牌。

帧类型只有三种:subscribed(订阅确认)、tick(实时行情)、error(错误帧,含 msg 与 code)。聚合器必须显式处理 error 帧,否则一次 UPSTREAM_UNAVAILABLE 会被静默吞掉,表现为数据流「突然安静」。

最小可用聚合器

下面的实现只依赖标准库,可直接运行。它用合成 tick 驱动,不消耗真实配额。

Python
from datetime import datetime, timezone, timedelta

INTERVAL = timedelta(minutes=1)
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)


def bucket_start(ts: datetime) -> datetime:
    """Floor a UTC timestamp to the interval boundary (left-closed)."""
    return EPOCH + (ts - EPOCH) // INTERVAL * INTERVAL


def parse_at(raw: str) -> datetime:
    """Normalise an RFC3339 tick timestamp to aware UTC."""
    return datetime.fromisoformat(raw.replace("Z", "+00:00"))


class Aggregator:
    def __init__(self, symbol: str):
        self.symbol = symbol
        self.cur = None
        self.bars = []

    def add(self, tick: dict) -> None:
        ts, price = parse_at(tick["at"]), float(tick["last"])
        start = bucket_start(ts)
        if self.cur is None or start > self.cur["openTime"]:
            if self.cur:
                self.bars.append(self.cur)
            self.cur = {"openTime": start, "open": price, "high": price,
                        "low": price, "close": price, "volume": 0.0}
        self.cur["high"] = max(self.cur["high"], price)
        self.cur["low"] = min(self.cur["low"], price)
        self.cur["close"] = price
        self.cur["volume"] += float(tick.get("volume") or 0)

    def flush(self) -> list:
        if self.cur:
            self.bars.append(self.cur)
            self.cur = None
        return self.bars


if __name__ == "__main__":
    ticks = [
        {"at": "2026-09-26T02:00:05Z", "last": 100.0, "volume": 2},
        {"at": "2026-09-26T02:00:41Z", "last": 103.5, "volume": 1},
        {"at": "2026-09-26T02:02:03Z", "last": 101.0, "volume": 4},
    ]
    agg = Aggregator("BTCUSDT")
    for t in ticks:
        agg.add(t)
    for b in agg.flush():
        print(b["openTime"].isoformat(), b["open"], b["high"],
              b["low"], b["close"], b["volume"])

运行结果会打印两根 bar:02:00 与 02:02。02:01 那一分钟没有 tick,因此不产生 bar——这正是上面强调的空窗口语义。若换成「补 0」,输出就会凭空多出一根收盘价为 0 的假 bar。

迟到 tick、乱序与宽限期

真实网络下,tick 的到达顺序与 tick.at 的先后顺序并不一致。多路复用、重连补偿或上游批量推送都会让某一根已封存的 bar 迟到地收到新数据。上面那份最小实现的判断条件是 start > self.cur["openTime"],它对乱序是脆弱的:一个迟到 tick 会被直接计入当前窗口,污染 high 与 low。

工程上的做法是引入宽限期:维护最近 N 个窗口的桶,在宽限期内允许回填历史桶;超出宽限期的 tick 丢弃并计数。宽限期长度与业务容忍度挂钩,常见取值是 2 到 5 个 interval。同时至少要暴露三个计数器:late_ticks、dropped_ticks、gap_windows。这三个数字是判断「数据流看起来正常但其实已经漏了」的唯一依据。

顺带一提成本结构:流式握手计入日配额与 RPM,而握手之后的 tick 推送不按条计费。因此长连接比 REST 轮询更省配额,具体档位与配额见 定价页。

与 REST 历史 K 线对账

聚合器写完并不等于正确。上线前必须用 REST 历史 K 线做一次对账,确认自建口径与官方口径一致。对账的第一个障碍是字段映射——各市场 bar 的字段名不同:

字段语义美股 K 线 bar加密货币 K 线 bar
开盘时间openTime(Ms)openTime / openTimeMs
OHLCopen / high / low / closeopen / high / low / close
成交量volumevolume / quoteVolume
成交笔数tradestrades
专属字段vwap / sessiontakerBuyVolume / takerBuyQuoteVolume

对账器的逻辑是把本地 bar 按开盘时间索引,逐字段比对,并把差异分成三类。差异分类比「不一致」三个字有用得多,因为它直接指向根因:

Python
from datetime import datetime, timedelta, timezone

INTERVAL = timedelta(minutes=1)
FIELDS = ("open", "high", "low", "close")


def reconcile(local_bars: list, rest_bars: list, tol: float = 1e-6) -> dict:
    """Diff locally aggregated bars against REST kline bars.

    missing  : REST has the window, we do not (grace period or gap).
    extra    : we have the window, REST does not (timezone or symbol bug).
    mismatch : both have it, a field value differs beyond tolerance.
    """
    index = {b["openTime"]: b for b in rest_bars}
    report = {"missing": [], "extra": [], "mismatch": []}
    for b in local_bars:
        key = b["openTime"]
        r = index.pop(key, None)
        if r is None:
            report["extra"].append(key)
            continue
        for f in FIELDS:
            if abs(float(b[f]) - float(r[f])) > tol:
                report["mismatch"].append((key, f, b[f], r[f]))
    report["missing"] = sorted(index)
    return report


if __name__ == "__main__":
    t0 = datetime(2026, 9, 26, 2, 0, tzinfo=timezone.utc)
    local = [{"openTime": t0, "open": 100.0, "high": 103.5,
              "low": 100.0, "close": 103.5}]
    rest = [{"openTime": t0, "open": 100.0, "high": 103.5,
             "low": 100.0, "close": 103.5},
            {"openTime": t0 + INTERVAL, "open": 103.5, "high": 104.0,
             "low": 103.0, "close": 103.8}]
    print(reconcile(local, rest))

输出中 missing 含 02:01 窗口、extra 与 mismatch 为空——这正是上文提到的空窗口:本地正确地没有合成它,而 REST 历史里有。此时应判定为预期差异并加白名单,而不是去修聚合器。

三类差异的根因对应关系是稳定的:missing 多数源于宽限期丢弃或断流;extra 多数源于时区归一化错误或符号映射错误;mismatch 多数源于复权口径或成交量单位差异。注意不要跨市场直接比对 volume,各市场成交量单位不同,这个数字本身不可比。对账请求同样计入日配额与 RPM,建议在离线环境按日跑一次,而不是每次提交都跑。

常见问题

为什么本地聚合的 bar 和 REST 的收盘价偶尔差一点?

三种可能:一是该 bar 尚未收盘,REST 返回的是进行中窗口;二是宽限期丢弃了最后一个 tick;三是浮点比较未设容差。建议用 1e-6 的绝对容差,并对未收盘窗口跳过比对。

一个 WebSocket 连接能订阅多少标的?

按计划档位分档:免费版 1 个连接 / 10 个标的,标准版 3 / 25,专业版 10 / 50,企业版 50 / 200。触顶后服务端返回 WS_CONN_LIMIT 错误帧,此时应降级为分片订阅或改用 REST 快照,而不是无限重连。

聚合器需要把每根 bar 都持久化吗?

取决于用途。实时看板只需在内存保留滚动窗口;需要回放、复盘或训练模型的场景才落盘,此时应叠加录制器的断线续录与缺口检测能力,两者是互补关系。

tick 推送会计入配额吗?

不会。计费口径是「握手计费、推送不计条」:流式握手计入日配额与每分钟请求数,握手之后推送的 tick 不再单独计量。这也是生产环境应优先使用长连接而非轮询的原因。

聚合器的价值不在代码本身,而在它把「实时」与「历史」放到了同一套口径下:前者驱动指标,后者做验收。建议先把对账器接进 CI,让每次口径改动都能被差异分类拦住;再按业务容忍度调宽限期,而不是凭感觉拍一个值。

起步成本为零:免费版提供 300 次/天调用、延迟的加密货币与美股、金属数据,免信用卡,且包含 WebSocket 流式能力,足以跑通本文全部代码。需要全市场实时与更高配额时,再按用量升级。市场与标的清单可先在 加密货币市场页 确认,接入细节参考 WebSocket 实时行情接入指南,各市场 K 线参数以 美股 K 线接口文档 为准。

本文为技术实现说明,不构成投资建议,亦不预测任何标的价格。文中接口参数、字段与配额均引自 TIQEX 官方文档与定价页(2026-09-26 核对)。