目录
- 一、凌晨两点半,监控屏上的数字不动了
- 二、"最新价"在不同时区下的语义分裂
- 三、比报错更可怕的,是"看起来没问题"
- 四、取值降级链:用状态机收敛四种数据时效
- 五、代码实现(含异常处理与多线程同步)
- 六、从多个数据源到一个统一接口
- [七、从 4 个指数到 40,000 个品种](#七、从 4 个指数到 40,000 个品种)
一、凌晨两点半,监控屏上的数字不动了
去年冬天凌晨 2:37,我被叫醒。
"全球指数面板上恒生指数不动了,停在 19842 已经两个多小时。数据源是不是挂了?"
排查路径:数据网关 → 上游推送 → Redis 缓存 → 前端 WS 连接。全链路正常。心跳一秒一个,日志没有异常。
排查耗时 2 小时 15 分钟 。最后定位:恒指下午 4 点就收市了,但 last_price 没归零、没变 null,只是停在最后一笔快照上不再更新。程序没报错,监控没告警,一切看起来都"正常"。
前端同事说了一句让我记到现在的话:
"你的数据没问题。但用户盯着恒指一条直线、其他指数全在跳,看了十分钟------他的第一反应是你的程序卡死了。"
跨时区指数监控最隐蔽的坑:不是取不到数据,而是取了数据却不知道它已经"死了"。
二、"最新价"在不同时区下的语义分裂
同一时刻取"最新价",四个市场返回四种语义:
北京时间上午 11:00
沪深300 → last_price = 实时成交价(正在交易)
恒生指数 → last_price = 实时成交价(正在交易)
标普500 → last_price = 昨天凌晨的收盘快照(已收市 19 小时)
德国DAX → last_price = 前一天收盘价(还没开盘)
四种时效性,但面板上全叫"最新价"------这是跨时区数据的语义分裂。
要解决这个问题,数据层需要把"最新价"拆成不同时效的端点。以 TickDB 为例,它提供了三层:
| 端点 | 返回什么 | 时效性 | 用在哪 |
|---|---|---|---|
ticker |
最新一笔快照 | 实时/最近 | 交易时段展示 |
kline/latest |
最近一根闭合 K 线 | 最近周期收盘价 | 闭市时展示 |
kline |
历史 K 线 | 已闭合 | 历史对比、涨跌幅 |
有了这三层,取值逻辑就不再是"一个字段硬扛",而是根据市场交易状态切换端点。这是设计取值降级链的前提。
三、比报错更可怕的,是"看起来没问题"
实测中我发现了一个比归零更致命的行为。
测试条件 :北京时间 2026-05-18 12:46 ,A 股午休时段,调 ticker 端点查沪深 300。
实际返回:
json
{
"symbol": "000300.SH",
"last_price": "4826.192",
"timestamp": 1779075006000
}
last_price = 4826.192,code = 0,一切正常。
但把 timestamp 换算一下:
1779075006000 → 2026-05-18 11:30:06(北京时间)
这是上午 11:30 最后一笔快照。此刻已经 12:46,停了 76 分钟。
你的面板上:价格没变、时间戳停在过去、程序没报错------如果你只判 code == 0 或 last_price > 0,这个"数据停摆"完全不会被发现。
预期 vs 实际行为:
| 直觉预期 | 实际行为 | |
|---|---|---|
| 闭市/午休时 ticker | 返回 null 或 0,触发异常 | 保持最后一笔快照,code=0,字段完整 |
| 对你的代码影响 | 报错 → 你知道有问题 | 静默通过 → 面板显示停摆数据,你不知道 |
跨时区指数监控的坑表:
| 坑 | 为什么踩 | 后果 | 正确处理 |
|---|---|---|---|
| 闭市返回静态值 | ticker 不归零,停摆无告警 | 用户以为程序卡了 | now - timestamp > 阈值 → 降级 |
| 市场归类偏差 | SPX/COMP 在 GLOBAL 不在 US |
market=US 查不到 |
美股指数用 market=global, type=indices |
| 节假日遗漏 | 只判星期几不管节假日 | 圣诞休市标"交易中" | 节假日日历 或 依赖降级链兜底 |
| 冬夏令时偏移 | 本地时间判断美股 | 切换日偏移 1 小时 | 统一 UTC + pytz 时区对象 |
四、取值降级链:用状态机收敛四种数据时效
核心思路:先判断市场是否在交易,再选择取值端点。两条路径,四种时效标记。
类比操作系统的缺页中断:首选路径不可用时,逐级降级,而不是直接报错或返回假值。
┌──────────────┐
│ 判断交易时段 │
└──────┬───────┘
│
┌─────────────┴─────────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ 交易时段 │ │ 非交易时段 │
└─────┬─────┘ └─────┬─────┘
│ │
▼ ▼
ticker.last_price kline/latest.close
│ │
▼ ▼
时效检查: ts + 阈值 时效检查: time + 阈值
│ │
┌──────┴──────┐ ┌──────┴──────┐
▼ ▼ ▼ ▼
有效 超时 有效 超时
realtime delayed close stale
两类市场的四种标记:
| 状态 | 取值端点 | 时效标记 | 用户看到什么 |
|---|---|---|---|
| 交易中,ticker 正常 | ticker | realtime |
实时价格 |
| 交易中,ticker 超时 | kline/latest | delayed |
最近 K 线收盘价 + "延迟"标记 |
| 闭市,kline 正常 | kline/latest | close |
今日收盘价 + "已收市"标记 |
| 闭市太久,kline 也旧了 | kline/latest | stale |
收盘价 + "非最新"标记 |
| 完全无数据 | --- | unavailable |
"暂无数据" |
凌晨 2:37 的恒指,走路径 B → close → 前端显示"已收市 | 最近收盘价 19842.16"。用户一看就懂,不会怀疑面板卡死。
五、代码实现(含异常处理与多线程同步)
安装:
bash
pip install tickdb-python requests pytz websocket-client
完整代码:
python
"""
跨时区指数监控 - 取值降级链实现
覆盖:沪深300(000300.SH) / 恒生指数(HSI) / 标普500(SPX) / 纳斯达克(COMP)
"""
import os, time, json, threading, logging
from datetime import datetime
from typing import Dict, Optional, Tuple
import pytz, requests
from websocket import WebSocketApp
# ============================================================
# 1. 基础配置
# ============================================================
logging.basicConfig(level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("IndexMonitor")
API_KEY = os.getenv("TICKDB_API_KEY", "")
if not API_KEY:
raise EnvironmentError("请设置环境变量 TICKDB_API_KEY")
BASE_URL = "https://api.tickdb.ai/v1"
WS_URL = "wss://api.tickdb.ai/v1/realtime"
STALE_SECONDS = 120 # 超过此秒数认为数据过期
# ============================================================
# 2. 指数配置
# ============================================================
INDEX_CONFIG: Dict[str, dict] = {
"000300.SH": {"name": "沪深300", "tz": "Asia/Shanghai",
"open": "09:30", "close": "15:00",
"lunch": ("11:30","13:00"), "has_lunch": True},
"HSI": {"name": "恒生指数", "tz": "Asia/Hong_Kong",
"open": "09:30", "close": "16:00",
"lunch": ("12:00","13:00"), "has_lunch": True},
"SPX": {"name": "标普500", "tz": "America/New_York",
"open": "09:30", "close": "16:00", "has_lunch": False},
"COMP": {"name": "纳斯达克综合", "tz": "America/New_York",
"open": "09:30", "close": "16:00", "has_lunch": False},
}
# ============================================================
# 3. 交易时段判断
# ============================================================
def is_market_open(symbol: str, at_time: Optional[datetime] = None) -> bool:
"""判断指数是否在交易时段(午休算非交易)"""
cfg = INDEX_CONFIG.get(symbol)
if not cfg: return False
tz = pytz.timezone(cfg["tz"])
now = at_time.astimezone(tz) if at_time else datetime.now(tz)
if now.weekday() >= 5: return False
ot = datetime.strptime(cfg["open"], "%H:%M").time()
ct = datetime.strptime(cfg["close"], "%H:%M").time()
if not (ot <= now.time() <= ct): return False
if cfg["has_lunch"]:
ls, le = (datetime.strptime(cfg["lunch"][0], "%H:%M").time(),
datetime.strptime(cfg["lunch"][1], "%H:%M").time())
if ls <= now.time() <= le: return False
return True
# ============================================================
# 4. 取值降级链
# ============================================================
class ValueDegradationChain:
"""
状态机核心:
交易中 → ticker.last_price
闭市 → kline/latest.close
超时 → stale 标记
"""
def __init__(self, api_key: str, base_url: str):
self.base = base_url
self.sess = requests.Session()
self.sess.headers["X-API-Key"] = api_key
self.max_retry = 3
self.backoff = 1.0
def _call(self, endpoint: str, params: dict) -> Optional[dict]:
"""
统一 HTTP 调用,处理三种返回:
- code 0 → 成功
- code 3001 → 限流:读 Retry-After 头,指数退避重试
- code 1001 → 鉴权失败:立即阻断抛出异常
"""
for attempt in range(self.max_retry):
try:
r = self.sess.get(f"{self.base}{endpoint}",
params=params, timeout=10)
data = r.json()
code = data.get("code", -1)
if code == 0:
return data
if code == 3001: # 限流
wait = float(r.headers.get("Retry-After",
self.backoff * (2 ** attempt)))
logger.warning(f"限流(3001) 等{wait}s 重试#{attempt+1}")
time.sleep(wait); continue
if code == 1001: # 鉴权失败 → 阻断
raise PermissionError("鉴权失败(1001): 检查 API Key")
logger.error(f"API错误 code={code}")
return None
except requests.Timeout:
logger.warning(f"超时 重试#{attempt+1}")
time.sleep(self.backoff * (2 ** attempt)); continue
except requests.RequestException as e:
logger.error(f"网络错误 {e}"); return None
except PermissionError:
raise
logger.error("超过最大重试次数"); return None
def get_price(self, symbol: str) -> Tuple[Optional[float], str, Optional[int]]:
"""取值降级链主入口 → (价格, 标记, 时间戳ms)"""
now = int(time.time() * 1000)
stale_ms = STALE_SECONDS * 1000
in_session = is_market_open(symbol)
# 路径 A:交易时段 → ticker
if in_session:
res = self._call("/market/ticker", {"symbols": symbol})
if res and res.get("data"):
item = res["data"][0]
price, ts = float(item["last_price"]), item.get("timestamp", 0)
if price > 0 and (now - ts) < stale_ms:
return price, "realtime", ts
logger.info(f"{symbol} ticker超时→降级")
# 路径 B:非交易时段 / ticker超时 → kline/latest
res = self._call("/market/kline/latest", {"symbols": symbol})
if res and res.get("data"):
klines = res["data"][0].get("klines", [])
if klines:
k = klines[-1]
price, ts = float(k["close"]), k["time"]
if price > 0:
if in_session: return price, "delayed", ts
return price, "stale" if (now-ts) > stale_ms else "close", ts
return None, "unavailable", None
# ============================================================
# 5. WebSocket 线程安全缓存
# ============================================================
class TickCache:
"""写入线程(WS回调)与读取线程(主循环)共享,必须加锁"""
def __init__(self):
self._data: Dict[str, dict] = {}
self._lock = threading.Lock()
def set(self, sym, v):
with self._lock: self._data[sym] = v
def get(self, sym):
with self._lock: return self._data.get(sym)
class WSClient:
"""
WebSocket 实时推送。
⚠️ 实测:推送为扁平JSON(无cmd/data包装层),symbol 无后缀。
"""
def __init__(self, api_key, cache: TickCache):
self.cache = cache
# symbol 无后缀 → 完整代码
self._map = {"000300":"000300.SH","HSI":"HSI","SPX":"SPX","COMP":"COMP"}
self.ws = None
def _on_msg(self, ws, msg):
try:
d = json.loads(msg)
sym = self._map.get(d.get("symbol",""), d.get("symbol",""))
self.cache.set(sym, {
"last_price": float(d.get("last_price",0)),
"timestamp": d.get("timestamp",0),
})
except: pass
def _on_open(self, ws):
ws.send(json.dumps({"cmd":"subscribe","channel":"ticker",
"symbols": list(self._map.keys())}))
logger.info("WS已连接并订阅")
def start(self):
self.ws = WebSocketApp(f"{WS_URL}?api_key={API_KEY}",
on_open=self._on_open, on_message=self._on_msg)
threading.Thread(target=self.ws.run_forever, daemon=True).start()
def stop(self):
if self.ws: self.ws.close()
# ============================================================
# 6. main
# ============================================================
def main():
chain = ValueDegradationChain(API_KEY, BASE_URL)
cache = TickCache()
ws = WSClient(API_KEY, cache)
ws.start(); time.sleep(3)
try:
while True:
now_utc = datetime.now(pytz.UTC)
print(f"\n{'='*50}")
print(f" {now_utc.astimezone(pytz.timezone('Asia/Shanghai')):%Y-%m-%d %H:%M:%S} 北京时间")
print(f"{'指数':<16} {'价格':>10} {'时效':>10} {'状态'}")
print("-"*50)
for sym, cfg in INDEX_CONFIG.items():
in_s = is_market_open(sym, now_utc)
st = "交易中" if in_s else "已收市"
if in_s:
ws_d = cache.get(sym)
if ws_d and ws_d["last_price"] > 0 and \
int(time.time()*1000) - ws_d["timestamp"] < STALE_SECONDS*1000:
print(f"{cfg['name']:<16} {ws_d['last_price']:>10.2f} {'ws_realtime':>10} {st}")
continue
price, tag, _ = chain.get_price(sym)
if price:
print(f"{cfg['name']:<16} {price:>10.2f} {tag:>10} {st}")
else:
print(f"{cfg['name']:<16} {'N/A':>10} {'unavailable':>10} {st}")
time.sleep(10)
except KeyboardInterrupt:
ws.stop()
if __name__ == "__main__":
main()
代码核心是 ValueDegradationChain.get_price() 的状态机设计,不是调 API。 两条路径 + 四种标记,让上游不用猜这个价格是否实时。
多线程同步的关键是 TickCache------WebSocket 回调线程写入、主线程读取,用 Lock 防止并发修改字典触发 RuntimeError。
六、从多个数据源到一个统一接口
做第一版时同时接了三个数据源。不同数据源在品种代码格式、闭市行为、时区表示上各自不同------维护成本分散在各处调用逻辑中。
多数据源各自为政时的典型问题:
| 问题 | 表现 | 成本 |
|---|---|---|
| 品种代码不一致 | 沪深300 在不同源里是 000300.SH / SH000300 / CSI300 |
每个源维护映射表 |
| 闭市行为不一致 | 源 A 返回 null,源 B 返回 0,源 C 返回收盘价不变 | 每源单独写边界逻辑 |
| 时区格式不一致 | 源 A UTC 时间戳,源 B 美东时间字符串 | 转换散落各处 |
| 市场归类不统一 | SPX 在源 A 的 US,源 B 的 global |
查询参数试错 |
| 字段语义漂移 | volume 在源 A 是股数,源 B 是成交额 |
字段名同、单位不同 |
统一接口应该提供什么:
| 能力 | 作用 |
|---|---|
代码格式统一(如 .SH/.HK/GLOBAL 无后缀) |
不用写映射表 |
| 端点分层 + 闭市行为确定 | 不用猜数据源闭市返回什么 |
| 时间戳全毫秒 UTC | 不用做时区转换 |
| 一套鉴权覆盖所有市场 | 鉴权逻辑写一次 |
TickDB 的 ticker/kline/kline-latest 三层端点和统一的字段体系,在这个场景下提供的是确定的取值行为------闭市返回什么、字段叫什么、时间戳什么单位,这些是确定的,你的降级链才敢依赖它们做自动切换。
七、从 4 个指数到 40,000 个品种
当前降级链在指数场景下靠 INDEX_CONFIG 的时区表驱动。但如果监控范围从 4 个指数扩展到 Crypto、外汇、全球正股------超过 40,000 个品种------给每个品种维护时区配置就不现实了。
更极简的方案:纯时间戳滑窗。
完全放弃时区表,用"数据心跳"判断活性:
资产状态 = "活跃" if now - timestamp < 阈值
= "静止" if 阈值 < now - timestamp < 超时
= "离线" if now - timestamp > 超时
这套逻辑对所有资产类型通用,只要数据层提供一致的 last_price + timestamp 字段即可零修改覆盖。时序判定替代日历规则,配置成本归零。
两种方案怎么选:
| 方案 A:时区表驱动 | 方案 B:时间戳滑窗 | |
|---|---|---|
| 适用资产 | 指数、正股(有明确交易时段) | Crypto、外汇、任意品种 |
| 闭市感知 | 瞬间主动标记"已收市" | 等超时后被动发现 |
| 维护成本 | 每市场维护时区+节假日 | 零配置 |
| 适用规模 | < 100 品种 | 40,000+ 品种 |
两者不互斥------指数场景用方案 A 做主动标记,Crypto/外汇用方案 B 做通用兜底,同一套降级链可以同时跑两套判定逻辑。
你踩过"数据看起来正常但实际已停摆"的坑吗?你的监控面板发现停摆花了多久?评论区聊。
📡 数据由 TickDB.ai 提供
- API:
https://api.tickdb.ai - WebSocket:
wss://api.tickdb.ai/v1/realtime - 文档:
https://docs.tickdb.ai - MCP:
https://mcp.tickdb.ai