休市提醒日股将于 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 实时行情接入指南。