休市提醒日股將於 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;示例代碼與參數僅用於說明工程實踐,不構成任何投資建議,也不承諾任何收益。