把實時行情「錄下來」看似只是把推送寫進文件,真做起來會撞上四個問題:斷線窗口怎麼補、重複 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)。連接後的第一條消息必須是訂閱報文,只有三個字段。
{
"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 日期分片還能限制單文件大小,並讓按天回放與刪除變成一次文件操作。
#!/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 排序,否則會把亂序誤判成缺口。
#!/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 差值等比縮放,用事件循環的單調時鐘等待,不受系統時間調整影響。
#!/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())最後把四項指標隨數據集歸檔:
- 覆蓋率:實錄交易時段時長 / 應錄時長,低於 99% 應在文件名上標註。
- 缺口清單:起止時間與秒數,按秒數降序,便於判斷是否影響結論。
- 重複計數:按
(symbol, at)統計,正常應為 0。 - 延遲分佈:
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 實時行情接入指南。