自建 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 核對)。