每一筆行情裡的三個時鐘:時間戳來源與時鐘偏差
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 步進校正、快照恢復、手工改時間、容器遷移到另一臺宿主機、閏秒處理不一致,都會讓牆鍾瞬間移位。開篇那次故障就是跳變,而且它可以用零成本的本地比較抓出來:
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 仍在走,這是你在週末僅能拿到的服務端時鐘信號。
反向邊界:這些場景不該做這件事
這套測量要付出三樣成本:一個判斷分支、一個落庫字段、一份採樣預算。六種場景不值得付。
- 日線與收盤後研究。
openTimeMs是桶的標籤,不是事件時刻。拿它算年齡,數字會在盤中一路上漲、在換桶瞬間歸零。決策週期是天級時,決定對錯的是交易日曆與還原規則,時鐘精度改不動結論。 - 收盤標的與冷門標的。 年齡在設計上就沒有上界。03:00 UTC 查一個美股標的,報價幾小時前是正常的。做法是先判斷交易時段,或直接讀
delayed與unavailable標記,把"年齡過大"告警限制在時段內生效。 - 無法納管的機器。 內網隔離環境裡,不要去估一個你無法驗證的偏差。做法是在到達時間旁邊寫一個
recv_clock_untrusted = true,所有判斷改用事件時間。承認未知比給出錯誤上界便宜,而錯誤上界會在凌晨兩點把你叫起來。 - 界面上的"幾秒前更新"。 用瀏覽器時鐘算相對時間,等於把一臺剛從睡眠恢復的筆記本變成故障源。相對時間放在服務端算,或者只顯示事件時間戳,別的都不顯示。
- 去重鍵與排序鍵。 到達時間永遠不能當鍵。一次跳變就會重排你的數據流:去重窗口要麼收縮放進重複,要麼放寬吞掉真實成交。鍵用
(標的, 事件時間, 價格),到達時間單獨存列。相關的失效形態見重連後的重複 tick與 K 線聚合對賬兩篇。 - 十毫秒級的服務等級承諾。 客戶端視角的年齡撐不起毫秒結論,單個觀測點的傳輸下限就蓋過了你要測的東西。那種精度需要接入側埋點、PTP,或者兩者都有。寫清楚你的承諾覆蓋你能觀測的部分。
採樣預算要單獨說,因為它會悄無聲息地讓這套探針失效。免費套餐每月 9,000 次 REST 調用、每日上限 300 次(定價頁,2026-10-20 訪問)。每兩秒探一次、跑滿一天是 43,200 次調用,是每日上限的 144 倍。所以按窗口跑:五分鐘間隔、十分鐘一輪是 120 次調用,每週輪換一臺機器,一週內仍能抓到一次跳變。需要連續監控時改走 WebSocket:握手只計一次額度,後續推送不按條計費;但流式接入需要 Growth 及以上套餐,具體額度與實時層級以定價頁和你的控制台為準。
探針
下面這段用推送流做測量,和 REST 版本的區別在於樣本來源是 tick 幀裡的 at,每秒可以拿到多個樣本,因此更適合觀察傳輸下限的抖動。它同樣把牆鍾和單調鍾在同一行取,把跳變量單獨計數。
#!/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 及以上版本,僅作工程示範:沒有重試預算、沒有持久化、沒有處理重定向。本文只示範工程實踐,不構成投資建議;行情數據僅供參考。