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

從零寫一個實時行情錄製器:斷線續錄、缺口檢測與可回放數據集

把 WebSocket 行情流落成可回放的數據集,難點不在寫入,而在不丟、不重、可續、可校驗。本文給出可直接運行的錄製器、缺口檢測腳本與回放器,並說明 WebSocket 握手計費如何影響採集成本。

把實時行情「錄下來」看似只是把推送寫進文件,真做起來會撞上四個問題:斷線窗口怎麼補、重複 tick 怎麼去重、跨天分片怎麼寫不壞、數據怎麼證明完整。 本文給出一套可直接運行的實時行情錄製實現:用 WebSocket 訂閱 wss://api.tiqex.io/api/v1/market/stream,把 tick 落成按 UTC 日期分片的 JSONL,輸出缺口與重複報告,並按原始節奏回放。

前提是已跑通「連接並打印行情」。只需最新價的話 REST 快照足夠;錄製器的價值在於採集只在當下存在的數據——逐筆成交、買賣盤口、毫秒級時間戳。

為什麼必須現在錄,而不是事後補歷史

常見想法是「等要用了再拉歷史」。這在日線級別成立,到 tick 級別就失效,因為兩類數據的可得性完全不同。

  • 可回溯:日線與分鐘 OHLCV,屬聚合結果,任何時候都能重新拉取。
  • 不可回溯:逐筆成交、買賣盤口、成交量增量,以及本地接收時刻與行情時間戳的差值。

第二類只在推送那一瞬間存在,當時沒落盤,事後補不回來。這直接決定tick 級回測是否可行:若策略依賴盤口或成交明細,採集必須提前開始,且從第一天起就完整。

另一個收益是延遲基線:同時寫入行情時間戳與本地接收時間,就積累出一條延遲曲線,日後「行情變慢」的爭議有數據可依。字段構成見 tick 數據術語頁。

錄製器的四條硬要求

先定標準再寫代碼。四個要求必須同時滿足,任一失守都會讓數據集在關鍵時刻不可用。

要求典型失敗模式對策
不丟進程被 kill,內存緩衝區裡的 tick 全部蒸發攢批寫入 + flush() + fsync()
不重重連後重復訂閱,同一筆 tick 被寫兩次以 (symbol, at) 作為冪等鍵去重
可續斷線窗口內的行情永久缺失且無人知曉維護水位線,重連後用 REST 快照對齊
可校驗文件有幾百 MB,但不知道全不全輸出缺口報告與覆蓋率指標

注意後兩條的關係:缺口本身不可怕,可怕的是一份「看起來完整」的數據集。 沒有缺口報告時,回測結果異常會讓人先懷疑策略,而不是先懷疑數據。

第一步:WebSocket 訂閱與幀處理

端點為 wss://api.tiqex.io/api/v1/market/stream,鑑權頭 X-API-Key(也接受 Authorization: Bearer)。連接後的第一條消息必須是訂閱報文,只有三個字段。

JSON
{
  "action": "subscribe",
  "market": "crypto",
  "symbols": ["BTCUSDT", "ETHUSDT"]
}

響應統一使用 succ / data / msg / code 信封,data.type 決定這一幀怎麼處理。

data.type含義錄製器的處理
subscribed訂閱確認,帶 market 與 symbols記錄確認,重置重連退避
tick實時行情,含 symbol / last / at 與 bid / ask / volume歸一化後寫入緩衝區
error錯誤幀,帶 msg 與 code記錄並判斷是否值得重試

一個影響採集成本的細節:WebSocket 握手計入每日額度與 RPM,推送的 tick 不按條重複計費,長連接的額度消耗與「重連次數」掛鉤,而非推送條數。字段與錯誤碼見 WebSocket 流式接口文檔。

標的數受套餐的連接數與標的數上限約束(Free 約 1 條 / 10 個,Pro 約 10 條 / 50 個)。超出時應按市場分組開多條連接,超訂會收到 WS_CONN_LIMIT。

第二步:落盤格式與分片策略

建議先寫 JSONL,再離線轉 Parquet,兩者寫入路徑的複雜度差異很大。

格式寫入複雜度崩潰安全適用階段
JSONL極低,純追加好,最多丟最後一行實時採集
Parquet高,需攢夠行組再落盤一般,需原子替換離線分析
SQLite中,受寫鎖與事務影響好小規模查詢

採集階段的核心訴求是「進程隨時被殺都不損壞已有數據」,JSONL 天然滿足;按 UTC 日期分片還能限制單文件大小,並讓按天回放與刪除變成一次文件操作。

Python
#!/usr/bin/env python3
"""實時行情錄製器:把 WebSocket tick 流落成按 UTC 日期分片的 JSONL。
依賴:pip install websockets
運行:TIQEX_API_KEY=你的密鑰 python3 recorder.py
"""
import asyncio
import json
import os
import time
from datetime import datetime
from pathlib import Path

import websockets

WS_URL = "wss://api.tiqex.io/api/v1/market/stream"
API_KEY = os.environ["TIQEX_API_KEY"]     # 從環境變量讀,不要寫死在代碼裡
MARKET = "crypto"
SYMBOLS = ["BTCUSDT", "ETHUSDT"]
OUT_DIR = Path("ticks")
FLUSH_EVERY = 200                         # 攢夠 200 條落一次盤


class Sink:
    """帶緩衝的追加寫入器,同一天複用同一個文件句柄。"""

    def __init__(self) -> None:
        OUT_DIR.mkdir(exist_ok=True)
        self._fh = None
        self._day = None
        self._buf: list[str] = []
        self.total = 0

    def add(self, record: dict) -> None:
        ts = datetime.fromisoformat(record["at"].replace("Z", "+00:00"))
        day = ts.strftime("%Y-%m-%d")
        if day != self._day:              # 跨天:換分片文件
            self.flush()
            if self._fh:
                self._fh.close()
            self._fh = open(OUT_DIR / f"{MARKET}-{day}.jsonl", "a", encoding="utf-8")
            self._day = day
        self._buf.append(json.dumps(record, ensure_ascii=False))
        self.total += 1
        if len(self._buf) >= FLUSH_EVERY:
            self.flush()

    def flush(self) -> None:
        if self._buf and self._fh:
            self._fh.write("\n".join(self._buf) + "\n")
            self._fh.flush()              # 先過 Python 緩衝
            os.fsync(self._fh.fileno())   # 再落盤,進程被殺也不丟
            self._buf.clear()


def normalize(frame: dict) -> dict | None:
    data = frame.get("data") or {}
    if data.get("type") != "tick":
        return None
    return {
        "symbol": data.get("symbol"),
        "at": data.get("at"),                      # RFC3339 行情時間
        "last": data.get("last"),
        "bid": data.get("bid"),
        "ask": data.get("ask"),
        "volume": data.get("volume"),
        "recvMs": int(time.time() * 1000),         # 本地接收時間,用於算延遲
    }


async def record() -> None:
    sink = Sink()
    backoff = 1

    while True:
        try:
            async with websockets.connect(
                WS_URL,
                additional_headers={"X-API-Key": API_KEY},  # websockets<14 用 extra_headers
                ping_interval=20,
                ping_timeout=20,
                max_queue=4096,                             # 隊列滿則丟棄,不阻塞
            ) as ws:
                await ws.send(json.dumps({
                    "action": "subscribe",
                    "market": MARKET,
                    "symbols": SYMBOLS,
                }))
                backoff = 1                                 # 連上即重置退避
                async for raw in ws:
                    frame = json.loads(raw)
                    if not frame.get("succ"):
                        print(f"[error] {frame.get('code')} {frame.get('msg')}")
                        continue
                    data = frame.get("data") or {}
                    if data.get("type") == "subscribed":
                        print(f"[ack] {data.get('market')} {data.get('symbols')}")
                        continue
                    rec = normalize(frame)
                    if rec is not None:
                        sink.add(rec)
        except (websockets.ConnectionClosed, OSError) as exc:
            print(f"[reconnect] {type(exc).__name__} -> retry in {backoff}s")
            await asyncio.sleep(backoff)
            backoff = min(backoff * 2, 30)                  # 指數退避,封頂 30 秒
        except asyncio.CancelledError:
            sink.flush()
            print(f"[done] 共寫入 {sink.total} 條 tick")
            raise


if __name__ == "__main__":
    asyncio.run(record())

三個刻意的選擇:其一,flush() 內同時調用 flush() 與 fsync(),前者只交給操作系統,後者才真正落盤。其二,重連成功後立即重置退避,否則一次抖動會把退避永久推高。其三,max_queue 設上限,寧可丟最新 tick,也不能因內存堆積被 OOM 殺掉而丟掉整個緩衝區。

第三步:斷線續錄與缺口檢測

重連要區分兩類斷開:網絡抖動與鑑權 / 權限問題。前者指數退避重連,後者重連再多次也沒用。判據是錯誤幀的 code:NO_SUBSCRIPTION、SUBSCRIPTION_EXPIRED、MARKET_NOT_ENABLED、API_KEY_SCOPE_DENIED 屬配置問題,應退出並報警;UPSTREAM_UNAVAILABLE 與 RATE_LIMIT_UNAVAILABLE 屬上游瞬時故障,值得重試。

斷線期間缺失的行情無法從流裡補回,但可用 REST 快照做邊界對齊:重連並收到第一個 tick 後取一次快照,記錄缺口閉合時刻的價格。接口是 GET https://api.tiqex.io/api/v1/market/quote?codes=BTCUSDT,ETHUSDT,codes 逗號分隔、單次最多 50 個,響應同樣是四字段信封,其中 delayed 與 unavailable 標記該條是延遲兜底還是不可用。

下面的腳本掃描 JSONL 輸出缺口與重複統計。一個易踩的坑:tick 到達順序不保證與行情時間戳一致,多市場混錄時更明顯,檢測前必須按 at 排序,否則會把亂序誤判成缺口。

Python
#!/usr/bin/env python3
"""掃描錄製的 JSONL,輸出缺口 / 重複 / 覆蓋率報告。"""
import json
from datetime import datetime
from pathlib import Path

GAP_SEC = 60          # 交易時段內超過 60 秒沒有 tick,記為缺口


def parse(ts: str) -> datetime:
    return datetime.fromisoformat(ts.replace("Z", "+00:00"))


def scan(path: Path) -> dict:
    rows = [json.loads(l) for l in path.read_text(encoding="utf-8").splitlines() if l]
    rows.sort(key=lambda r: r["at"])           # 亂序到達是常態,先排序

    by_symbol: dict[str, list[dict]] = {}
    for r in rows:
        by_symbol.setdefault(r["symbol"], []).append(r)

    report = {"file": path.name, "rows": len(rows), "duplicates": 0, "symbols": {}}
    for symbol, series in by_symbol.items():
        gaps, dups = [], 0
        prev = None
        for r in series:
            ts = parse(r["at"])
            if prev is not None:
                delta = (ts - prev).total_seconds()
                if delta == 0:
                    dups += 1                  # 同一 (symbol, at) 重複到達
                elif delta > GAP_SEC:
                    gaps.append({"from": prev.isoformat(),
                                 "to": ts.isoformat(),
                                 "seconds": round(delta, 3)})
            prev = ts
        report["symbols"][symbol] = {"ticks": len(series),
                                     "gaps": gaps, "duplicates": dups}
        report["duplicates"] += dups
    return report


if __name__ == "__main__":
    for f in sorted(Path("ticks").glob("*.jsonl")):
        print(json.dumps(scan(f), ensure_ascii=False, indent=2))

報告裡該盯的不是 rows,而是 duplicates 與 gaps。重複說明去重鍵沒生效,回測會被同一筆價格重複觸發;缺口說明那段時間的策略表現不可信。

第四步:回放與完整性報告

錄好的數據要能按原始節奏重放,否則離線測試「30 秒內兩次觸發」這類告警規則時,壓縮的時間軸會讓規則失效。回放器按 at 差值等比縮放,用事件循環的單調時鐘等待,不受系統時間調整影響。

Python
#!/usr/bin/env python3
"""按錄製時的真實節奏回放 tick,speed=10 表示 10 倍速。"""
import asyncio
import json
from datetime import datetime
from pathlib import Path


def parse(ts: str) -> datetime:
    return datetime.fromisoformat(ts.replace("Z", "+00:00"))


async def replay(path: Path, speed: float = 1.0):
    rows = [json.loads(l) for l in path.read_text(encoding="utf-8").splitlines() if l]
    rows.sort(key=lambda r: r["at"])
    t0_wall = parse(rows[0]["at"])
    t0_mono = asyncio.get_running_loop().time()
    for r in rows:
        target = (parse(r["at"]) - t0_wall).total_seconds() / speed
        wait = target - (asyncio.get_running_loop().time() - t0_mono)
        if wait > 0:
            await asyncio.sleep(wait)
        yield r                      # 消費方:策略、告警、看板


async def main():
    async for tick in replay(Path("ticks/crypto-2026-09-19.jsonl"), speed=10.0):
        print(tick["symbol"], tick["last"])


if __name__ == "__main__":
    asyncio.run(main())

最後把四項指標隨數據集歸檔:

  1. 覆蓋率:實錄交易時段時長 / 應錄時長,低於 99% 應在文件名上標註。
  2. 缺口清單:起止時間與秒數,按秒數降序,便於判斷是否影響結論。
  3. 重複計數:按 (symbol, at) 統計,正常應為 0。
  4. 延遲分佈:recvMs 減去行情時間戳,給出 p50 / p95 / p99,作為後續基線。

這四項固定下來,數據集就從「一堆文件」變成「可引用的資產」。

常見問題

錄製器會消耗大量 API 額度嗎?

取決於訂閱方式,而不是推送條數。握手計入每日額度與 RPM,推送的 tick 不按條重複計費,所以長連接的額度消耗主要由「重連次數」決定。真正吃額度的是用 REST 輪詢模擬實時行情:200 個標的秒級輪詢,一天就是千萬次請求量級。

斷線期間缺失的行情能事後補齊嗎?

逐筆成交與盤口不能補齊,它們只在推送瞬間存在;可以補齊的是聚合數據,K 線與日線能從歷史接口重新拉取。實務做法是用 K 線補齊價格走勢,但在缺口清單裡標註該時段沒有 tick 明細,避免把聚合數據當成 tick 數據使用。

為什麼按 UTC 日期分片,而不是本地日期?

因為跨市場錄製時,本地日期會把同一個交易時段切到兩個文件裡。以北京時間為例,美股常規交易時段橫跨本地凌晨,按本地日期分片會讓一個完整交易日被拆成兩片,跨天統計必然出錯。

免費套餐能跑錄製器嗎?

可以。Free 套餐 $0 起、免信用卡,含 300 次/天的額度與 WebSocket 流式能力,覆蓋延遲的加密、美股與金屬數據,足夠驗證錄製鏈路。免費檔的連接數與標的數上限較低,錄多個標的時應按分組開連接。要採集全市場實時 tick 與更長曆史,需升級到 對應套餐;加密市場的實時行情見 Crypto API 市場頁。

小結

錄製器的價值不在代碼行數,而在於它把一次性的實時流變成了可反覆使用的數據集。四個要求——不丟、不重、可續、可校驗——分別對應緩衝落盤、冪等鍵、退避重連與缺口報告。建議順序是:先跑通單標的錄製,確認文件裡有 at 與 recvMs;再補缺口檢測,確認 duplicates 為 0;最後接上回放器。

本文代碼只做數據採集與校驗,不構成投資建議,也不作收益性承諾;行情時延與完整性受網絡與上游源影響,生產環境請以實際觀測指標為準。想從最小示例開始,可先讀 WebSocket 實時行情接入指南。