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

每一筆行情裡的三個時鐘:時間戳來源與時鐘偏差

報價裡的 timestampMs 是交易所時間,你手裡的到達時間是本機時間,兩者之差同時包含傳輸與本機偏差,單次採樣拆不開。本文用窗口內的低點估出偏差上界,用牆鍾與單調鐘的差值捕捉跳變,並說明哪些場景不值得為此花掉採樣額度。

每一筆行情裡的三個時鐘:時間戳來源與時鐘偏差

09:25,A 股集合競價結束前五分鐘,你的行情告警服務跑在一臺剛遷移過宿主機的虛擬機上。遷移過程裡宿主時鐘比標準時間慢 2.7 秒,容器啟動後 chrony 還在緩慢校準。你的去重邏輯按到達時間開窗:同一標的、三秒內、價格相同即判為重複。宿主機慢下來的那 2.7 秒,把 11:02 到 11:06 之間連續幾筆同價成交壓進了同一個窗口,真實成交被當成重複丟棄,告警沒有觸發。當天盤後對賬,這幾分鐘的記錄是空的,日誌裡沒有任何錯誤——去重函數按設計正常工作,只是它拿到的時間基準錯了。

這次故障和"行情斷了"不是一回事。連接一直在,訂閱一直確認,幀一直在到。出問題的是你對"這一幀是什麼時候發生的"的判斷,而這個判斷依賴三個不同來源的時鐘,你只控制了其中一個。

每一筆行情都帶著別人的時鐘到達,又在你進程裡被打上第三個時鐘。到達時間減事件時間同時包含網絡傳輸和本機時鐘偏差,一個等式兩個未知數,單次採樣無法拆開;只有把一段窗口內的低點當作下限來讀,才能把兩者分開。

三個問題,三件儀器

上面那次故障的根因,是同一個數字被要求回答三個不同的問題。拆開之後,儀表反而變少。

你要回答的問題 用的儀器 單位 被什麼汙染 讀數出錯的樣子
這個價格現在還算數嗎 本機牆鍾減 timestampMs 毫秒 本機偏差、上游緩存時間 整個交易時段年齡持續上漲,開盤瞬間歸零
到接入點的往返要多久 請求前後各取一次單調鍾 毫秒 本機網絡路徑、TLS 複用、響應體大小 429 退避期間 p99 突然抬高
本機時鐘準不準 一段窗口內年齡的低點,或響應頭 Date 毫秒 傳輸下限、響應頭一秒量化 下限卡在 4000 毫秒,怎麼都降不下來

字段的來源要先分清,否則後面全是猜測:

字段 誰寫入 時鐘性質 能回答 不能回答
報價行 timestampMs 上游撮合或行情源 牆鍾,毫秒 這個價格是什麼時候成立的 平臺什麼時候發出的這一幀
推送幀 at 同上,RFC3339 字符串 牆鍾,帶時區 該筆 tick 在事件序列裡的位置 你收到它的時刻
K 線 openTimeMs 平臺按週期歸桶 桶起點,非事件時間 這根 K 線覆蓋哪一段 這根 K 線什麼時候定稿
響應頭 Date 接入層 牆鍾,秒級 服務端大概幾點(誤差約半秒) 亞秒級任何結論
time.time() 你的進程 牆鍾,可被跳變 你本機認為的到達時刻 它是否正確
time.monotonic() 你的進程 單調,只增不減 兩個動作之間隔了多久 絕對時間,且跨重啟無意義

有一個容易被忽略的事實:報價行裡的 timestampMs 是上游給這個價格打的時間,不是平臺把 HTTP 響應推出去的瞬間。對成交活躍、且處於交易時段的標的,兩者足夠接近;對成交稀疏的標的,或者任何已經收盤的標的,這個字段可以是幾小時前的,而且這是設計如此,不是故障。所以年齡必須按標的算,且只在該標的的交易時段內算。跳過時段判斷,你會得到每個週末都響一次的告警,和每週一早上報告六十小時偏差的時鐘看板。

第二個推論:年齡衡量的是新鮮度,不是延遲。如果你的服務等級目標需要一個延遲數字,年齡給不了,因為它包含上游自己的緩存和上游自己的時鐘。真正能歸因到你自己鏈路上的,是圍繞單次請求用單調鍾測出的往返時間;即便如此,那也只是客戶端視角的路徑耗時,不是單向傳輸時間。

用下限估出偏差的上界

把等式寫下來,估計量自然浮現:

年齡 = 到達牆鍾 − 事件時間
     = 傳輸耗時 + (本機時鐘 − 源時鐘)
     = 傳輸耗時 + 偏差

傳輸耗時不可能為負,所以每個樣本都滿足「年齡 ≥ 偏差」,一段窗口內的年齡低點就是「偏差 + 傳輸下限」。這是上界,不是點估計。本機時鐘準確、網絡傳輸下限 8 毫秒時,低點讀數是 8 毫秒,結論是「偏差不超過 8 毫秒」——正確且無害。本機快 3000 毫秒時,低點永遠壓不到 3000 毫秒以下,結論立刻有用:主機錯了數秒,你算出的每個年齡都同向錯了數秒。

估計量 算法 實際估的是什麼 失效方式
平均年齡 全部年齡求均值 偏差 + 平均傳輸 隨負載、重試、隊列深度漂移,把網絡天氣報成時鐘故障
中位數 p50 偏差 + 典型傳輸 仍然跟隨排隊,持續積壓會被讀成緩慢漂移
p95 95 分位 偏差 + 尾部傳輸 把尾延遲混進時鐘誤差,正是本文開頭那類誤判
窗口低點 段內取低 偏差 + 傳輸下限 單個異常時間戳就能帶偏,需配合單調性校驗
分窗口低點 每十分鐘取一次低點 偏差 + 下限,逐段可比 小於下限的跳變看不見

平均值是這裡錯誤的選擇。平均年齡等於偏差加平均傳輸,而平均傳輸裡混著重試、TLS 握手、調度停頓和 TCP 長尾;把噪聲平均進一個"下限"的估計裡,得到的數字跟著負載走,不跟著時鐘走。低點是收斂到下限的次序統計量,這也是各類時鐘同步算法都從低點或靠近低點的分位數起步的原因。

兩種偏差要分開命名,處置方式完全不同。

漂移是連續的。未納管的虛擬機時鐘以幾十到幾百 ppm 的速度累積誤差:100 ppm 等於每小時 360 毫秒,一天 8.6 秒。度量方式是每十分鐘取一次低點、連成序列、算斜率,單位毫秒每小時。斜率接近零隻說明穩定,不說明準確——恆定偏差的斜率也是零。

跳變是間斷的。NTP 步進校正、快照恢復、手工改時間、容器遷移到另一臺宿主機、閏秒處理不一致,都會讓牆鍾瞬間移位。開篇那次故障就是跳變,而且它可以用零成本的本地比較抓出來:

Python
t0w, t0m = time.time(), time.monotonic()
# ... 一次請求 ...
t1w, t1m = time.time(), time.monotonic()
step_ms = (t1w - t0w - (t1m - t0m)) * 1000

牆鍾會跳,單調鐘不會。兩秒的區間裡,這個差值超過約 1000 毫秒,說明動的是牆鍾而不是網絡。一次減法就能把 2.7 秒的遷移事故變成一條日誌。兩條注意事項讓它保持誠實:Linux 上 time.monotonic() 讀的是 CLOCK_MONOTONIC,機器掛起期間它停走,所以恢復後算出的差值很大,即使牆鍾完全正確,這時要用 CLOCK_BOOTTIME 或開機標識區分"牆鍾動了"和"單調鐘停了";另外 chrony 的默認行為是緩慢調整小偏差而不是步進,上限約 500 ppm,於是一次校正可以讓偏差越過你設的 200 毫秒閾值,而跳變檢測全程沉默。兩件事都要做:給上界設閾值,同時盯差值。

響應頭 Date 是第三件儀器,比它看起來的要弱。它只有秒級精度,量化誤差就有正負 500 毫秒,再疊加傳輸。做法是拿它做交叉校驗,容差給兩秒,不要拿它當精確偏差。它有一個不可替代的場合:標的收盤、timestampMs 停在幾小時前時,Date 仍在走,這是你在週末僅能拿到的服務端時鐘信號。

反向邊界:這些場景不該做這件事

這套測量要付出三樣成本:一個判斷分支、一個落庫字段、一份採樣預算。六種場景不值得付。

  1. 日線與收盤後研究。 openTimeMs 是桶的標籤,不是事件時刻。拿它算年齡,數字會在盤中一路上漲、在換桶瞬間歸零。決策週期是天級時,決定對錯的是交易日曆與還原規則,時鐘精度改不動結論。
  2. 收盤標的與冷門標的。 年齡在設計上就沒有上界。03:00 UTC 查一個美股標的,報價幾小時前是正常的。做法是先判斷交易時段,或直接讀 delayed 與 unavailable 標記,把"年齡過大"告警限制在時段內生效。
  3. 無法納管的機器。 內網隔離環境裡,不要去估一個你無法驗證的偏差。做法是在到達時間旁邊寫一個 recv_clock_untrusted = true,所有判斷改用事件時間。承認未知比給出錯誤上界便宜,而錯誤上界會在凌晨兩點把你叫起來。
  4. 界面上的"幾秒前更新"。 用瀏覽器時鐘算相對時間,等於把一臺剛從睡眠恢復的筆記本變成故障源。相對時間放在服務端算,或者只顯示事件時間戳,別的都不顯示。
  5. 去重鍵與排序鍵。 到達時間永遠不能當鍵。一次跳變就會重排你的數據流:去重窗口要麼收縮放進重複,要麼放寬吞掉真實成交。鍵用 (標的, 事件時間, 價格),到達時間單獨存列。相關的失效形態見重連後的重複 tick與 K 線聚合對賬兩篇。
  6. 十毫秒級的服務等級承諾。 客戶端視角的年齡撐不起毫秒結論,單個觀測點的傳輸下限就蓋過了你要測的東西。那種精度需要接入側埋點、PTP,或者兩者都有。寫清楚你的承諾覆蓋你能觀測的部分。

採樣預算要單獨說,因為它會悄無聲息地讓這套探針失效。免費套餐每月 9,000 次 REST 調用、每日上限 300 次(定價頁,2026-10-20 訪問)。每兩秒探一次、跑滿一天是 43,200 次調用,是每日上限的 144 倍。所以按窗口跑:五分鐘間隔、十分鐘一輪是 120 次調用,每週輪換一臺機器,一週內仍能抓到一次跳變。需要連續監控時改走 WebSocket:握手只計一次額度,後續推送不按條計費;但流式接入需要 Growth 及以上套餐,具體額度與實時層級以定價頁和你的控制台為準。

探針

下面這段用推送流做測量,和 REST 版本的區別在於樣本來源是 tick 幀裡的 at,每秒可以拿到多個樣本,因此更適合觀察傳輸下限的抖動。它同樣把牆鍾和單調鍾在同一行取,把跳變量單獨計數。

Python
#!/usr/bin/env python3
"""
tick_clock_probe.py —— 用推送幀的 at 字段估計本機時鐘偏差上界。

    TIQEX_API_KEY=... python tick_clock_probe.py

依賴:pip install websockets
權限:WebSocket 需 Growth 及以上套餐;免費套餐只含 REST 報價與 K 線。
計費:一次握手計一次額度,後續 tick 推送不計條數(定價頁 2026-10-20)。
"""

import asyncio
import json
import os
import statistics
import time
from datetime import datetime

import websockets

WS_URL = "wss://api.tiqex.io/api/v1/market/stream"
MARKET = "crypto"
SYMBOLS = ["BTCUSDT", "ETHUSDT"]
DURATION_S = 60
STEP_THRESHOLD_MS = 1000.0
IDLE_TIMEOUT_S = 5.0


def parse_at_ms(raw: str) -> float | None:
    """RFC3339 轉毫秒。帶 Z 的寫法先換成本地可解析的偏移量。"""
    if not raw:
        return None
    try:
        return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp() * 1000.0
    except ValueError:
        return None


async def probe(api_key: str) -> None:
    ages: dict[str, list[float]] = {}
    steps: list[float] = []
    frames = 0
    frames_without_ts = 0
    prev = None  # (牆鍾, 單調鍾)

    headers = {"X-API-Key": api_key}
    async with websockets.connect(WS_URL, additional_headers=headers, ping_interval=20) as ws:
        await ws.send(json.dumps({"action": "subscribe", "market": MARKET, "symbols": SYMBOLS}))

        loop = asyncio.get_running_loop()
        deadline = loop.time() + DURATION_S

        while loop.time() < deadline:
            try:
                raw = await asyncio.wait_for(ws.recv(), timeout=IDLE_TIMEOUT_S)
            except asyncio.TimeoutError:
                continue

            # 兩個時鐘在同一行取,保證描述的是同一瞬間
            recv_wall, recv_mono = time.time(), time.monotonic()
            try:
                frame = json.loads(raw)
            except json.JSONDecodeError:
                continue

            if not frame.get("succ"):
                print(f"錯誤幀:code={frame.get('code')} msg={frame.get('msg')}")
                break

            data = frame.get("data") or {}
            if data.get("type") != "tick":
                continue

            frames += 1
            if prev is not None:
                wall_delta = (recv_wall - prev[0]) * 1000.0
                mono_delta = (recv_mono - prev[1]) * 1000.0
                steps.append(wall_delta - mono_delta)
            prev = (recv_wall, recv_mono)

            event_ms = parse_at_ms(data.get("at"))
            symbol = data.get("symbol")
            if event_ms is None or not symbol:
                frames_without_ts += 1
                continue
            ages.setdefault(symbol, []).append(recv_wall * 1000.0 - event_ms)

    print(f"\n幀數:{frames},缺失時間戳:{frames_without_ts}")
    if not ages:
        print("沒有拿到可用樣本:檢查套餐是否含推送、標的代碼是否正確")
        return

    print(f"{'標的':<12}{'樣本':>6}{'低點':>10}{'中位':>10}{'p95':>10}{'上界':>10}")
    bounds = []
    spans = []
    for symbol, series in sorted(ages.items()):
        ordered = sorted(series)
        p95 = ordered[max(0, min(len(ordered) - 1, int(round(0.95 * len(ordered))) - 1))]
        bounds.append(ordered[0])
        spans.append(statistics.median(ordered) - ordered[0])
        print(f"{symbol:<12}{len(ordered):>6}{ordered[0]:>10.0f}"
              f"{statistics.median(ordered):>10.0f}{p95:>10.0f}{ordered[0]:>10.0f}")

    bound = max(bounds)          # 取差的標的,得到保守上界
    spread = max(spans)
    big_steps = [s for s in steps if abs(s) > STEP_THRESHOLD_MS]

    print(f"\n偏差上界:{bound:,.0f} 毫秒(含傳輸下限)")
    print(f"排隊離散:{spread:,.0f} 毫秒(中位減低點)")
    print(f"疑似跳變:{len(big_steps)} 次" + (f",幅度 {max(big_steps, key=abs):+,.0f} 毫秒"
                                          if big_steps else ""))

    if bound > 2000:
        print("\n結論:主機時鐘錯了數秒。先修 NTP,再談年齡、去重窗口和告警順序。")
    elif spread > 500:
        print("\n結論:時鐘可用,離散來自傳輸與排隊。抬新鮮度閾值,不要動超時。")
    else:
        print("\n結論:年齡小且穩定。保留交易時段判斷,把上界記進值班手冊。")


def main() -> None:
    api_key = os.environ.get("TIQEX_API_KEY")
    if not api_key:
        raise SystemExit("請先設置 TIQEX_API_KEY")
    asyncio.run(probe(api_key))


if __name__ == "__main__":
    main()

三段代碼裡有三個刻意的選擇,比輸出格式重要得多。

兩個時鐘在同一行取。把牆鍾放在函數入口、單調鍾放在函數返回,等於比較的是 DNS 解析前與 TLS 握手後的兩個時刻,跳變檢測會頻繁誤報,而你會以為是網絡問題。

上界按標的算完之後取更差的那個,不取更好的。一個對 BTCUSDT 成立、對冷門標的失效的上界,不是對時鐘的上界,是對運氣的上界。聚合之後仍然是上界,只是更保守。

結論分三態,而不是兩態。上界大,修時鐘;上界小而離散大,說明年齡被時鐘之外的東西主導,那是排隊或緩存問題,該換一本值班手冊;兩者都小,儀器可用,下一步是自動化而不是繼續測量。

決策清單

# 如果這條成立 就做這件事 不做的代價
1 你用本機牆鍾算年齡 先估出上界,再決定是否採信年齡 負年齡被當作髒數據丟棄,行情靜默中斷
2 你在時段內按價格告警 按事件時間排序,不按到達時間 一次跳變就讓告警亂序觸發
3 你用時間戳去重 鍵改 (標的, 事件時間, 價格),到達時間單列 時間倒退時窗口放寬,真實成交被吞
4 你落庫到達時間 同時落 clock_offset_bound_ms 半年後這列無法證偽,也當不了證據
5 你跑在虛擬機或容器上 每次快照恢復、遷移、打補丁後跑一次跳變檢測 一次掛起換 37 分鐘靜默丟數
6 你要對外承諾延遲 寫明這是客戶端往返,不是單向傳輸 數字只在沒人追問測法時站得住
7 探針要長期運行 改走推送流,或按每日上限編預算 兩秒一次的 REST 探針超免費上限 144 倍
8 標的已收盤 告警前先判斷時段 每個週末都成事故,然後你把告警靜音

常見問題

能不能從本機測出精確的單向延遲? 不能。一個等式兩個未知數,單個觀測點解不出來;聲稱給出單向傳輸時間的供應商,要麼在接入側埋了點,要麼在估計。你能做的是給兩者之和定上界——本文的做法——以及把往返時間歸因到自己的路徑,後者通常才是可行動的那一半。

年齡為什麼會是負數? 因為你的時鐘比源時鐘慢了超過傳輸耗時的量。這不構成數據損壞的證據。把它記成時鐘事件、加一個計數器、把這一幀留下。

有了 delayed 標記,還需要這套測量嗎? 判斷新鮮度,大體不需要:標記是供應商的權威陳述,不是從兩個時鐘推出來的結論,讀取成本為零。但它不告訴你本機時鐘是否錯了,而後者仍然影響去重窗口、告警順序和日誌關聯。

只信響應頭的 Date 行不行? 做精確偏差不行,秒級量化擺在前面。回答"本機是否錯了十秒以上"夠用;而且在收盤標的的時間戳故意不動時,它是此刻仍在走的服務端時鐘。

容差設多少? 從容差服務的決策倒推,不要從時鐘倒推。告警需要區分相隔 50 毫秒的兩筆成交,200 毫秒的偏差就已經讓排序失去意義;看板五秒刷新一次,兩秒的上界與結論無關,不值得為它花采樣額度。

回測要不要做這件事? 只有當你在分鐘以下粒度上拼接實時與歷史數據時才需要。否則主導因素是交易日曆與還原規則,見跨市場時區與日曆一篇。為了改善日線回測去測時鐘偏差,是改不動結論的工作。

下一步

按順序三步。第一步,在已經在調接口的進程里加那行跳變檢測,把結果和請求標識一起落日誌;成本是一次減法,一天之內就能知道你的機器會不會動。第二步,在每一臺產出可信時間戳的機器上跑一次上面的探針,把上界寫進值班手冊,而不是寫進聊天記錄。第三步,把交易時段判斷放到年齡告警前面,讓週末安靜下來,告警才保得住含義。

如果上界很大,那是平臺側的工作,不是應用側:給主機指一個可達的 NTP 源,確認出網規則沒有攔掉它,再檢查容器運行時是否屏蔽了宿主時鐘。如果上界很小而離散很寬,接著該讀的是六種靜默失效:時鐘正常而離散偏大,說明這是投遞問題穿著一件時鐘問題的外衣。


口徑與範圍。 本文的字段名、錯誤碼與額度取自 TIQEX API v1 開發者文檔 tiqex.io/docs/auth、tiqex.io/docs/errors、tiqex.io/docs/market-coverage,以及定價頁 tiqex.io/pricing,訪問日期均為 2026-10-20(文檔版本:API v1)。額度、單次報價的標的數上限(美股 20、跨市場 50、加密 100)、WebSocket 計費方式與服務等級約定屬於文檔默認值,按套餐而定;控制台中的套餐為準,延遲層級、歷史深度與市場覆蓋同樣取決於你的權限,引用前請在定價頁核對。代碼面向 Python 3.11 及以上版本,僅作工程示範:沒有重試預算、沒有持久化、沒有處理重定向。本文只示範工程實踐,不構成投資建議;行情數據僅供參考。