自建 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 驱动,不消耗真实配额。
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 |
| OHLC | open / high / low / close | open / high / low / close |
| 成交量 | volume | volume / quoteVolume |
| 成交笔数 | trades | trades |
| 专属字段 | vwap / session | takerBuyVolume / takerBuyQuoteVolume |
对账器的逻辑是把本地 bar 按开盘时间索引,逐字段比对,并把差异分成三类。差异分类比「不一致」三个字有用得多,因为它直接指向根因:
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 核对)。