做一個「同時看美股、A 股、港股、外匯、加密」的監控看板,難點不在前端圖表,而在數據層如何把不同市場的數據拉到同一張頁面上。多市場看板會遇到三個單市場看板不會有的問題:交易時段不一致、刷新節奏不一致、數據時效性不一致。
這篇文章講這三個問題怎麼在架構上解決。
問題一:交易時段不一致
多市場看板最直接的體驗問題是:打開頁面時,一半的標的是「無數據」狀態。
原因很簡單——美股、A 股、港股、外匯、加密的交易時段幾乎不重疊。你在北京時間上午 10 點打開看板,A 股正在交易,美股已經收盤 10 小時,加密則在 7×24 運轉。
如果看板把「無數據」一律渲染成空白或 0,用戶會誤以為系統故障。正確的做法是把市場狀態作為一等數據建模:
| 狀態 | 含義 | 前端呈現 |
|---|---|---|
| OPEN | 正在交易 | 顯示實時價格,正常刷新 |
| CLOSED | 休市 | 顯示最後收盤價 + 休市標識 |
| PRE / POST | 盤前 / 盤後 | 顯示價格但標註時段 |
| HALTED | 停牌 | 顯示停牌標識,不顯示「最後更新」 |
| UNAVAILABLE | 數據不可用 | 明確的錯誤態,而不是空白 |
關鍵區別是:休市不是故障。 把休市渲染成空白,用戶會反覆刷新甚至報障;渲染成「收盤價 + 休市標識」,用戶就知道這是正常狀態。
問題二:刷新節奏不一致
看板的刷新策略不能「一刀切」,因為不同市場的合理刷新頻率差異極大:
- 加密:7×24 交易,秒級刷新有意義。
- 股票:僅在交易時段刷新,休市時應該停止請求。
- 外匯:接近 7×24,但週末休市。
如果看板對所有標的一律秒級輪詢,會產生兩個後果:休市市場的請求全部是浪費,以及額度被無意義地消耗。以 200 個標的為例,全天候秒級輪詢是每月數億次請求,而其中大部分落在休市時段。
正確的架構是按市場狀態動態調整訂閱:
- 用交易所日曆判斷每個市場當前是否處於交易時段。
- 交易時段內:訂閱 WebSocket 流式推送,實時更新。
- 非交易時段:取消訂閱,只保留最後一次收盤快照。
- 市場開盤前若干分鐘:提前恢復訂閱,避免開盤瞬間的空窗。
這樣額度消耗與實際交易時段成正比,而不是與時鐘成正比。
問題三:時效性不一致
這是最容易被忽略、也最容易傷害用戶信任的一點。
看板上並排顯示的標可能來自不同時效的數據源:加密是實時的、美股是延遲的、某個市場可能是緩存快照。 如果前端不做區分,用戶會默認「這一屏都是實時數據」。
行情接口的響應裡帶有 delayed 與 unavailable 標誌,看板應該把它們一路透傳到界面:
- 每個標的卡片上顯示數據時間,而不只是「最後更新於剛剛」。
- 延遲數據用視覺標記區分(例如一個「延遲」角標)。
- 同一屏內如果時效性差異較大,給出整體提示。
這條做法的價值在於避免用戶基於錯誤的時間假設做決策。在一個混合市場看板上,這一點比價格本身的精度更重要。
架構分層
把上面的原則落到架構上,可以分成四層:
| 層 | 職責 | 關鍵設計 |
|---|---|---|
| 接入層 | 按市場狀態訂閱 / 退訂 | WebSocket + 交易所日曆驅動 |
| 狀態層 | 維護每個標的最新快照 | 內存或 Redis,帶時效性元數據 |
| 聚合層 | 按看板視角組織數據 | 支持按市場、按分組、按自選 |
| 推送層 | 增量推給前端 | 只推變化項,避免全量重繪 |
其中推送層只推變化項是關鍵優化。多市場看板往往有幾十到上百個標的,全量推送會讓前端每秒重繪整個頁面,而實際變化的可能只有兩三個。
Python 實現:按市場狀態驅動的訂閱管理
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# 各市場交易時段(交易所本地時間,簡化示例)
SESSIONS = {
"us": ("America/New_York", "09:30", "16:00"),
"hk": ("Asia/Hong_Kong", "09:30", "16:00"),
"cn": ("Asia/Shanghai", "09:30", "15:00"),
"crypto": (None, "00:00", "23:59"), # 7×24
}
WARMUP_MINUTES = 5 # 開盤前提前訂閱
def market_state(market: str, now_utc: datetime | None = None) -> str:
"""返回 OPEN / CLOSED,用於決定是否訂閱。"""
now_utc = now_utc or datetime.now(timezone.utc)
tz_name, start, end = SESSIONS[market]
if tz_name is None: # 7×24 市場
return "OPEN"
local = now_utc.astimezone(ZoneInfo(tz_name))
if local.weekday() >= 5: # 週末
return "CLOSED"
hhmm = local.strftime("%H:%M")
# 含開盤前預熱窗口
warm = (datetime.strptime(start, "%H:%M")
- __import__("datetime").timedelta(minutes=WARMUP_MINUTES)).strftime("%H:%M")
return "OPEN" if warm <= hhmm <= end else "CLOSED"
def desired_symbols(portfolio: dict[str, list[str]], now_utc=None) -> list[str]:
"""按當前市場狀態,算出「此刻應該訂閱的標的」。"""
wanted = []
for market, symbols in portfolio.items():
if market_state(market, now_utc) == "OPEN":
wanted.extend(symbols)
return wanted
PORTFOLIO = {
"crypto": ["BTCUSDT", "ETHUSDT"],
"us": ["AAPL", "MSFT"],
"hk": ["00700"],
"cn": ["600519"],
}
# 訂閱集合只隨市場狀態變化,不隨時鐘每秒變化
print("當前應訂閱:", desired_symbols(PORTFOLIO))
for m in PORTFOLIO:
print(f" {m:8} {market_state(m)}")
核心是 desired_symbols():它把「訂閱哪些標的」變成一個由市場狀態決定的函數。前端可以定時調用它做差集比較,只在集合變化時才調整 WebSocket 訂閱——這樣訂閱抖動降到最低,額度消耗也與真實交易時段對齊。
看板體驗的三個細節
- 顯示數據時間而不是「剛剛」:相對時間在多市場場景下會誤導,「2 分鐘前」在休市市場裡可能是「10 小時前」。
- 休市市場保留收盤價:空白會讓用戶以為加載失敗,收盤價加標識才是正確呈現。
- 區分「停牌」與「無數據」:前者是市場事件,後者是系統問題,前端表現和告警策略都應該不同。
常見問題
看板需要為每個市場建一條 WebSocket 連接嗎?
不一定。行情接口的流式訂閱支持按市場頻道訂閱,可以用較少連接覆蓋多個市場。關鍵在於按市場狀態動態調整訂閱集合,而不是連接數量。
休市時應該繼續輪詢嗎?
不應該。休市期間的請求不會返回新數據,純屬浪費額度。正確做法是休市時取消訂閱,只保留最後一次快照,並在開盤前幾分鐘提前恢復。
怎麼處理不同市場的時效性差異?
把接口返回的 delayed 與 unavailable 標誌一路透傳到前端,並在卡片上顯示數據時間。不要讓用戶在不知情的情況下把延遲數據當實時數據使用。
看板刷新頻率應該設多少?
按市場分別設定:加密可以秒級,股票只在交易時段刷新,外匯接近全天但週末休市。統一的高頻刷新在多市場場景下幾乎總是浪費。
前端應該全量刷新還是增量更新?
增量更新。多市場看板通常有幾十上百個標的,每秒真正變化的只有少數幾個,全量推送會導致不必要的重繪和帶寬消耗。
下一步
多市場看板的核心是讓訂閱集合跟著市場狀態走,而不是跟著時鐘走。流式訂閱協議見 WebSocket 流式接口文檔,多市場接入的整體設計見 多市場集成指南,各市場的交易時段可查閱 美股行情 與 港股行情 頁面,交易日曆可用 交易日曆工具 核對。訂閱規模與 套餐權限 相關,規劃時建議先確認標的數量上限。