休市提醒日股將於 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 核對)。