行情数据供应商迁移(market data vendor migration)通常不是技术难题,而是一次成本与风险的重新分配。当产品从单一市场扩展到股票、外汇、加密与大宗商品,团队往往已在三到四家供应商之间拼出一套采集层:每家一套鉴权、一套符号、一套字段语义、一套限流口径。把数据源收敛到一个统一 API,收益是运维面收窄,代价是迁移窗口内要同时维护两套链路。本文给出一条可执行的迁移路径:清点调用面、字段语义归一、双跑期差异比对、按消费者灰度切换,以及一份可逐条勾选的验收清单。
迁移的成本大头不是代码,而是双跑期
多数团队评估迁移时只看改造工时,这是最容易低估的一项。真实成本分三层,量级依次上升。
| 成本层 | 具体来源 | 可控手段 |
|---|---|---|
| 一次性改造 | 适配器重写、字段映射、符号归一 | 先出映射表与契约测试,再动代码 |
| 双跑期 | 两套链路并行、双份配额、双份告警与值班 | 缩短窗口,只对关键消费者双跑 |
| 隐性成本 | 对账口径不一致、回滚预案缺失、下游语义变更 | 迁移前把字段语义一次性固化 |
双跑期的长度决定成本量级。经验做法是按「一个完整业务周期」设计窗口,让它至少覆盖一次月末结算、一次大幅波动、一次供应商侧异常。只跑一个平静的交易日,等于没有验证——恰恰是异常路径才暴露映射缺陷。
双跑期的配额容易被忽略:影子流量会真实消耗额度。按站点口径,REST 的 quote / kline 请求与 WebSocket 握手都计入日配额与每分钟请求数,握手之后的推送不按条重复计费。因此影子比对应尽量走 WebSocket 订阅,而不是对同一批符号高频轮询;量级先用 配额测算工具 算清,再决定窗口长度。
第一步是清点调用面,不是写适配器
迁移失败的常见形态是「先写新客户端,边写边发现漏了什么」。正确顺序是先产出一张调用台账,四列:端点、符号、字段、消费者。
| 列 | 记录内容 | 用途 |
|---|---|---|
| 端点 | 路径、参数、调用频次 | 确定需要哪些新接口与配额量级 |
| 符号 | 原始符号、所属市场、命名风格 | 建立符号归一映射 |
| 字段 | 实际读取的字段名与类型 | 确定映射表覆盖范围 |
| 消费者 | 哪个服务或界面在用,容忍度如何 | 确定切换顺序与验收标准 |
关键判断在第四列。按端点分组会低估工作量:同一端点常被多个消费者以不同假设使用,交易终端假设 last 是实时成交价,风控假设 bid 与 ask 一定存在,报表假设 volume 可以跨源累加。按消费者分组,才谈得上分批切换。
清点还会暴露隐性依赖:写死的字段名、在下游临时做的时区转换、对延迟数据的静默接受。这些不会出现在接口文档里,只会出现在代码里。站点的 多市场接入指南 讲的是在统一 API 上从零搭建,本篇讲的是已有链路如何迁过去,两者是先后关系而非替代关系。
字段映射:把多套语义压到一套
字段映射不是改名,是语义归一。改名一行代码就够,语义归一需要一张表加一组契约测试。
| 目标字段 | 多源常见差异 | 归一规则 |
|---|---|---|
| last | 有的源是最新成交,有的是上一根收盘 | 统一取最新成交;无值时置 null 并置 unavailable |
| bid / ask | 部分市场或部分档位不提供 | 缺失即 null,禁止用 last 回填 |
| timestampMs | 秒、毫秒、带时区字符串混杂 | 统一为毫秒整数 |
| delayed | 延迟数据在部分源没有任何标记 | 统一映射为布尔标记,禁止静默使用 |
| volume | 口径不一,有的含撤单 | 标注口径,不做跨源累加 |
两条原则。其一,缺失与零必须区分:0 是一个真实值,null 是「没有值」,用 0 回填缺失会让下游把停牌当成暴跌。其二,语义转换只做一次,放在适配层,不要留给下游各自处理——下游每多一处转换,就多一处长期漂移点。
统一 quote 接口的字段口径是现成的参照:响应信封为 succ / data / msg / code,data 内含 code / symbol / market / category、last / bid / ask / volume、change / changePercent / high / low、timestampMs,以及 delayed 与 unavailable 两个布尔标记。把适配层输出对齐到这套字段,下游几乎不用改;单次 codes 上限 50,外汇与商品类代码如 EURUSD、XAUUSD 可批量取,覆盖面见 外汇行情页。
双跑期:影子流量与差异比对
双跑的核心是同一批符号同时请求新旧两条链路,逐字段比对。比对规则必须带容差,否则输出全是噪声。
三类容差缺一不可:价格按相对偏差(千分之几量级),时间戳按滞后上限(几秒),成交量按量级(百分比)。此外要把「一侧有值、一侧为空」单独归类为映射缺陷——它不是市场波动,是代码错误,必须单独计数。
# parity_check.py — compare two vendor snapshots field by field
# Prices move between calls, so exact equality is the wrong test.
TOL = {"last": 0.005, "bid": 0.005, "ask": 0.005, "volume": 0.05}
MAX_LAG_MS = 5_000
FIELDS = ("last", "bid", "ask", "volume", "timestampMs")
def _rel(a, b):
return abs(a - b) / (max(abs(a), abs(b)) or 1.0)
def compare(code, old, new):
out = []
if bool(old.get("delayed")) != bool(new.get("delayed")):
out.append((code, "delayed", "flag", old.get("delayed"), new.get("delayed")))
for f in FIELDS:
a, b = old.get(f), new.get(f)
if f == "timestampMs":
if a and b and abs(a - b) > MAX_LAG_MS:
out.append((code, f, "lag", a, b))
continue
if a is None or b is None:
if a != b: # one side missing is a mapping bug, not volatility
out.append((code, f, "missing", a, b))
continue
if _rel(a, b) > TOL.get(f, 0.005):
out.append((code, f, "value", a, b))
return out
def report(pairs):
diffs = [d for code, old, new in pairs for d in compare(code, old, new)]
for code, field, kind, a, b in diffs:
print(f"{code:<10} {field:<12} {kind:<8} {a} -> {b}")
return diffs
if __name__ == "__main__":
report([
("EURUSD", {"last": 1.0850, "bid": 1.0849, "ask": 1.0851, "volume": 1200,
"timestampMs": 1_757_000_000_000, "delayed": False},
{"last": 1.0851, "bid": 1.0850, "ask": 1.0852, "volume": 1210,
"timestampMs": 1_757_000_002_000, "delayed": False}),
("XAUUSD", {"last": 2410.5, "bid": None, "ask": None, "volume": 300,
"timestampMs": 1_757_000_000_000, "delayed": True},
{"last": 2410.9, "bid": 2410.4, "ask": 2410.6, "volume": 302,
"timestampMs": 1_757_000_000_500, "delayed": False}),
])
输出要按符号与字段聚合,而不是打印流水。单个符号偶发偏差属正常;同一字段在多个符号上系统性偏差,几乎可以断定映射规则写错了。运行期还需记录每类差异的占比,把它作为切换的前置门槛:差异率不降到预设阈值以下,就不进下一步。
切换与回滚:按消费者灰度,不按端点
切换粒度选错,会把可控风险变成不可控。按端点切,一次改动同时影响所有消费者,出问题时无法定位责任面;按消费者切,可以先把一个非关键消费者整体迁过去,观察完整业务周期后再推进下一个。
分流必须确定性:同一消费者、同一符号在整个窗口内固定落在同一侧,否则同一张看板上两个字段会来自不同供应商,这种问题比数据错误更难排查。用消费者与符号的哈希取模分桶,比随机数安全。
# cutover.py — shift consumers, not endpoints
import hashlib
CONSUMERS = {
"web-terminal": {"pct": 100, "fallback": "legacy"},
"risk-monitor": {"pct": 25, "fallback": "legacy"},
"batch-backfill": {"pct": 0, "fallback": "legacy"},
}
def _bucket(consumer, symbol):
# Deterministic: one consumer and symbol must stay on one side
# for the whole window, or a dashboard mixes two vendors.
key = f"{consumer}:{symbol}".encode()
return int(hashlib.sha256(key).hexdigest()[:8], 16) % 100
def route(consumer, symbol):
rule = CONSUMERS.get(consumer)
if rule is None:
return "legacy" # an unknown consumer never gets the new feed
return "unified" if _bucket(consumer, symbol) < rule["pct"] else rule["fallback"]
def rollback(consumer):
"""One line puts a consumer back on the old feed, no redeploy."""
CONSUMERS[consumer]["pct"] = 0
if __name__ == "__main__":
for c in CONSUMERS:
hits = sum(route(c, f"SYM{i}") == "unified" for i in range(200))
print(f"{c:<16} pct={CONSUMERS[c]['pct']:>3} routed_new={hits}/200")
rollback("risk-monitor")
print("after rollback:", route("risk-monitor", "SYM7"))
回滚开关必须是一行配置就能生效的动作,而不是重新部署。上面这段的 rollback() 把该消费者的比例直接归零,下一次调用即回到旧链路。切换期间旧链路与旧密钥必须保持可用,观察期结束前不要吊销。限流侧同样要重新测算:401、403、429 三类错误建议按消费者维度分别告警,错误码口径见 限流与错误码术语,响应头 X-Request-Id 应全量落库以便对账。
迁移验收清单
迁移是否完成,不取决于新链路能否返回数据,而取决于下列条目能否逐条给出证据。
- 台账中每个消费者的每个端点在双跑期都有比对记录,差异率低于预设门槛
- 字段映射表覆盖全部字段,「缺失」与「零」的处理有明文规则
- 时间戳全部为毫秒整数,且抽样核对过至少一个跨时区市场
delayed与unavailable标记在下游被真实读取,而不是被忽略- 限流与配额按新链路重新测算,且已把影子流量计入
- 401 / 403 / 429 按消费者维度分别告警,
X-Request-Id全量落库 - 回滚开关经过一次真实演练,确认无需重新部署
- 旧链路与旧密钥在观察期结束后才下线
常见问题
双跑期应该跑多久?
按业务周期而非天数计算。至少覆盖一次月末结算、一次显著波动、一次上游异常。缺少异常样本的双跑期,无法验证降级路径与告警是否有效。
影子流量会额外消耗配额吗?
会。REST 的 quote 与 kline 请求、以及 WebSocket 握手都计入日配额与每分钟请求数,握手之后的推送不按条计费。所以影子比对优先用 WebSocket 订阅同一批符号,比高频轮询更省,也更接近生产形态。
迁移期间下游服务要不要改代码?
目标是零改动。前提是适配层把字段语义归一到位——字段名、单位、时间戳精度、缺失值语义全部对齐后再放流量。若下游必须改,说明映射表有缺口,应先补表再切换。
能不能一次把所有消费者切过去?
不建议。按消费者分批的价值在于保留对照组:新链路出问题时,未切换的消费者仍能正常工作,业务连续性不受影响,同时比对数据仍在产生。一次全切等于主动放弃这个对照。
迁移顺序是确定的:先出调用台账,再定字段映射,然后开双跑窗口,最后按消费者灰度切换。前两步只花人力不花时间,却决定了后两步的风险上限。
文中接口路径、字段口径、错误码与限流档位均取自 tiqex.io 官方文档(更新于 2026-09-12)与定价页,访问日期 2026-09-24;示例代码与参数仅用于说明工程实践,不构成任何投资建议,也不承诺任何收益。