做一个「同时看美股、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 流式接口文档,多市场接入的整体设计见 多市场集成指南,各市场的交易时段可查阅 美股行情 与 港股行情 页面,交易日历可用 交易日历工具 核对。订阅规模与 套餐权限 相关,规划时建议先确认标的数量上限。