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

从多个行情供应商迁移到统一 API:双跑期、字段映射与切换验收

当采集层由三到四家供应商拼成,收敛到一个统一行情 API 的收益是运维面收窄,代价是迁移窗口内同时维护两套链路。本文给出可执行的迁移路径:调用面台账、字段语义归一表、双跑期差异比对、按消费者灰度切换与回滚开关,附一份可逐条勾选的验收清单。

行情数据供应商迁移(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 可批量取,覆盖面见 外汇行情页。

双跑期:影子流量与差异比对

双跑的核心是同一批符号同时请求新旧两条链路,逐字段比对。比对规则必须带容差,否则输出全是噪声。

三类容差缺一不可:价格按相对偏差(千分之几量级),时间戳按滞后上限(几秒),成交量按量级(百分比)。此外要把「一侧有值、一侧为空」单独归类为映射缺陷——它不是市场波动,是代码错误,必须单独计数。

Python
# 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}),
    ])

输出要按符号与字段聚合,而不是打印流水。单个符号偶发偏差属正常;同一字段在多个符号上系统性偏差,几乎可以断定映射规则写错了。运行期还需记录每类差异的占比,把它作为切换的前置门槛:差异率不降到预设阈值以下,就不进下一步。

切换与回滚:按消费者灰度,不按端点

切换粒度选错,会把可控风险变成不可控。按端点切,一次改动同时影响所有消费者,出问题时无法定位责任面;按消费者切,可以先把一个非关键消费者整体迁过去,观察完整业务周期后再推进下一个。

分流必须确定性:同一消费者、同一符号在整个窗口内固定落在同一侧,否则同一张看板上两个字段会来自不同供应商,这种问题比数据错误更难排查。用消费者与符号的哈希取模分桶,比随机数安全。

Python
# 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 应全量落库以便对账。

迁移验收清单

迁移是否完成,不取决于新链路能否返回数据,而取决于下列条目能否逐条给出证据。

  1. 台账中每个消费者的每个端点在双跑期都有比对记录,差异率低于预设门槛
  2. 字段映射表覆盖全部字段,「缺失」与「零」的处理有明文规则
  3. 时间戳全部为毫秒整数,且抽样核对过至少一个跨时区市场
  4. delayed 与 unavailable 标记在下游被真实读取,而不是被忽略
  5. 限流与配额按新链路重新测算,且已把影子流量计入
  6. 401 / 403 / 429 按消费者维度分别告警,X-Request-Id 全量落库
  7. 回滚开关经过一次真实演练,确认无需重新部署
  8. 旧链路与旧密钥在观察期结束后才下线

常见问题

双跑期应该跑多久?

按业务周期而非天数计算。至少覆盖一次月末结算、一次显著波动、一次上游异常。缺少异常样本的双跑期,无法验证降级路径与告警是否有效。

影子流量会额外消耗配额吗?

会。REST 的 quote 与 kline 请求、以及 WebSocket 握手都计入日配额与每分钟请求数,握手之后的推送不按条计费。所以影子比对优先用 WebSocket 订阅同一批符号,比高频轮询更省,也更接近生产形态。

迁移期间下游服务要不要改代码?

目标是零改动。前提是适配层把字段语义归一到位——字段名、单位、时间戳精度、缺失值语义全部对齐后再放流量。若下游必须改,说明映射表有缺口,应先补表再切换。

能不能一次把所有消费者切过去?

不建议。按消费者分批的价值在于保留对照组:新链路出问题时,未切换的消费者仍能正常工作,业务连续性不受影响,同时比对数据仍在产生。一次全切等于主动放弃这个对照。

迁移顺序是确定的:先出调用台账,再定字段映射,然后开双跑窗口,最后按消费者灰度切换。前两步只花人力不花时间,却决定了后两步的风险上限。

查看各档位的配额、RPM 与 SLA →

文中接口路径、字段口径、错误码与限流档位均取自 tiqex.io 官方文档(更新于 2026-09-12)与定价页,访问日期 2026-09-24;示例代码与参数仅用于说明工程实践,不构成任何投资建议,也不承诺任何收益。