把实时行情「录下来」看似只是把推送写进文件,真做起来会撞上四个问题:断线窗口怎么补、重复 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 实时行情接入指南。