Python 全球指数监控:闭市数据停摆了,你的面板为什么没告警

12:46 · 4826.192 · 76 分钟

北京时间 12:46,沪深 300 的 last_price 停在 4826.192,timestamp 换算后是 11:30:06。午休 76 分钟了,面板纹丝不动。
code == 0,通过。last_price > 0,通过。心跳正常。全链路无报错。
用户盯了十分钟,结论是——“程序卡死了”。

跨时区指数监控最隐蔽的坑,不是取不到数据,而是取了数据却不知道它已经“死了”。你写的每一行 if last_price 检查,都被闭市时的“完美返回”安静绕过。


为什么标普 500 的“最新价”是 19 小时前的?

同一时刻取“最新价”,四个市场返回四种语义:

北京时间上午 11:00

沪深300  → last_price = 实时成交价(正在交易)
恒生指数 → last_price = 实时成交价(正在交易)
标普500  → last_price = 19 小时前的收盘快照(已收市)
德国DAX  → last_price = 前一天收盘价(还没开盘)

面板上只展示一个数字。用户不会去算纽约现在几点,他的默认假设是“这就是现在能交易到的价格”。当标普 500 的“最新价”其实是昨夜收盘价时,这个数字在今天完全无法成交——但没人告诉用户。

解决这个问题,数据层需要把“最新价”拆成不同时效的端点。https://api.tickdb.ai 提供了三层:

端点 返回什么 时效性 用在哪
ticker 最新一笔快照 实时/最近 交易时段展示
kline/latest 最近一根闭合 K 线 最近周期收盘价 闭市时展示
kline 历史 K 线 已闭合 历史对比、涨跌幅

有了这三层,取值逻辑就不再是“一个字段硬扛”,而是根据市场交易状态切换端点。这是降级链的前提。


为什么 code=0 反而最危险?

/v1/market/ticker 查沪深 300,北京时间 12:46,A 股午休。

实际返回:

{
  "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 分钟。

你的面板上:价格没变、时间戳停在过去、程序没报错。

直觉预期 vs 实际行为

你直觉上以为的 实际发生的
闭市/午休时 ticker 返回 null 或 0,触发异常 保持最后一笔快照,字段完整
对你代码的影响 报错 → 你知道有问题 静默通过 → 你完全不知道

如果你写的是 if last_price is None 来做兜底——这行代码在生产环境里永远不会触发。

坑表速查

原因 后果 正确做法
闭市返回静态值 ticker 不归零,code=0 面板假死,用户怀疑程序卡了 now - timestamp > 阈值 → 降级
SPX/COMP 查不到 美股指数在 GLOBAL 不在 US 以为接口故障 market=global, type=indices
节假日误标“交易中” 只判星期几 圣诞休市显示实时价格 节假日日历 或 降级链兜底
冬夏令时偏移 本地时间判断美股 切换日偏差 1 小时 统一 UTC + pytz

取值降级链:像缺页中断一样思考

一句话类比:操作系统的缺页中断——首选路径不可用时逐级降级,而不是直接报错或返回假值。

① 是什么

取值降级链:先判断市场是否在交易,再选择取值端点。交易中用 ticker,闭市或超时降级到 kline/latest。

② 为什么非用不可

ticker 闭市后不报错不归零,唯一判断数据“活着”的方式是 now - timestamp。不用降级链,你只能靠一堆 if-else 硬扛四种市场状态,散落在各个文件里。

③ 两种方案,选哪个?

方案 A:时区表驱动 方案 B:时间戳滑窗
核心逻辑 用各市场 open/close/午休 精确判断 只用 now - timestamp < 阈值
闭市感知 主动标记“已收市”,即时 等超时后被动发现
适用 指数、正股(有明确交易时段) Crypto、外汇(24 小时交易)
选择条件 品种有明确交易时段且 < 100 个 → 选 A 品种无交易时段概念或 > 10000 → 选 B

本文走方案 A——指数品种少,需要主动标记“已收市”让用户一眼看懂。

④ 还有什么坑

原因 后果
ticker 闭市不归零 保持最后一笔快照 面板假死
美股指数归类偏差 SPX/COMP 在 GLOBAL market=US 查不到
午休误判闭市 ticker 停推但心跳正常 触发不必要的重连

⑤ 优化:两条路径 + 四种标记

                     ┌──────────────┐
                     │  判断交易时段  │
                     └──────┬───────┘
                            │
              ┌─────────────┴─────────────┐
              ▼                           ▼
       ┌───────────┐               ┌───────────┐
       │  交易时段  │               │  非交易时段 │
       └─────┬─────┘               └─────┬─────┘
             │                           │
             ▼                           ▼
     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/latest stale 收盘价 + “非最新”
无数据 unavailable “暂无数据”

凌晨 2:37 的恒指走路径 B → close → 前端显示“已收市 | 收盘价 19842.16”。用户一看就懂。


代码实现

依赖安装:

pip install requests pytz websocket-client

完整代码(关键行已标注):

"""
跨时区指数监控 - 取值降级链
覆盖:沪深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

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          # 超过此时长认为数据过期

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},
}

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 = datetime.strptime(cfg["lunch"][0], "%H:%M").time()
        le = datetime.strptime(cfg["lunch"][1], "%H:%M").time()
        if ls <= now.time() <= le:
            return False
    return True


class ValueDegradationChain:
    """降级链:交易中→ticker,闭市→kline/latest,超时→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) -> dict:
        """
        统一 HTTP 调用,显式处理三种错误码。
        3001 限流读 Retry-After 退避;1001 鉴权阻断;其他非0 显式 raise。
        """
        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:                    # 限流:优先读 Retry-After
                    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")
                raise RuntimeError(f"未预期错误: code={code}, body={data}")  # ← 非0必须raise
            except requests.Timeout:
                logger.warning(f"超时 重试#{attempt+1}")
                time.sleep(self.backoff * (2 ** attempt))
                continue
            except PermissionError:
                raise
            except requests.RequestException as e:
                logger.error(f"网络错误 {e}")
                if attempt == self.max_retry - 1:
                    raise RuntimeError(f"超过最大重试次数: {e}")
                time.sleep(self.backoff * (2 ** attempt))
        raise RuntimeError("超过最大重试次数")

    def get_price(self, symbol: str) -> Tuple[Optional[float], str, Optional[int]]:
        """
        降级链主入口 → (价格, 时效标记, 时间戳ms)。
        ticker 用 last_price / timestamp;kline 用 close / time。
        """
        now = int(time.time() * 1000)
        stale_ms = STALE_SECONDS * 1000
        in_session = is_market_open(symbol)

        # 路径 A:交易时段 → ticker
        if in_session:
            try:
                res = self._call("/market/ticker", {"symbols": symbol})
                items = res.get("data", [])       # ticker 数组直接在 data 下
                if items:
                    item = items[0]
                    price = float(item["last_price"])
                    ts = item.get("timestamp", 0)
                    if price > 0 and (now - ts) < stale_ms:
                        return price, "realtime", ts  # ← 唯一的实时出口
                    logger.info(f"{symbol} ticker超时→降级")
            except RuntimeError:
                logger.exception(f"{symbol} ticker失败→降级")

        # 路径 B:非交易时段 / ticker超时 → kline/latest
        try:
            res = self._call("/market/kline/latest", {"symbols": symbol})
            data = res.get("data", [])
            if data:
                klines = data[0].get("klines", [])   # 嵌套路径 data[0].klines
                if klines:
                    k = klines[-1]
                    price = float(k["close"])
                    ts = k["time"]
                    if price > 0:
                        if in_session:
                            return price, "delayed", ts
                        return price, "stale" if (now - ts) > stale_ms else "close", ts  # ← 闭市/过期判定
        except RuntimeError:
            logger.exception(f"{symbol} kline失败")

        return None, "unavailable", None


class TickCache:
    """
    线程安全缓存:WebSocket 回调线程写入,主线程读取。
    字典并发读写可能触发 RuntimeError,必须加锁保护。
    """
    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,symbol 无市场后缀。"""

    def __init__(self, api_key, cache: TickCache):
        self.cache = cache
        self._map = {"000300":"000300.SH", "HSI":"HSI", "SPX":"SPX", "COMP":"COMP"}  # ← WS symbol映射
        self.ws = None

    def _on_msg(self, ws, msg):
        try:
            d = json.loads(msg)         # 扁平 JSON,无 cmd/data 包装
            raw_sym = d.get("symbol", "")
            sym = self._map.get(raw_sym, raw_sym)
            self.cache.set(sym, {
                "last_price": float(d.get("last_price", 0)),
                "timestamp": d.get("timestamp", 0),
            })
        except Exception:
            pass

    def _on_open(self, ws):
        ws.send(json.dumps({"type": "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,
            ping_interval=1,   # 心跳由底层自动维护
            ping_timeout=5
        )
        threading.Thread(target=self.ws.run_forever, daemon=True).start()

    def stop(self):
        if self.ws:
            self.ws.close()


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:
                        age = int(time.time() * 1000) - ws_d["timestamp"]
                        if age < 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()

核心是 get_price() 里的两条路径 + 四种标记,不是 API 调用本身。 _call() 中 3001 读 Retry-After、1001 阻断、非 0 显式 raise——三种错误全部分支处理。TickCache 的锁保护 WebSocket 回调线程与主线程之间的字典读写。


没有统一接口时,你在维护什么

三行问题速览:

  • 品种代码不一致:沪深 300 在三个源里是 000300.SH / SH000300 / CSI300,每个源一张映射表。
  • 闭市行为不一致:源 A 返回 null,源 B 返回 0,源 C 返回收盘价不变,边界逻辑各自写。
  • 市场归类不一致:SPX 在这个源的 US,在那个源的 GLOBAL,查询参数靠试错。

TickDB 作为一个统一的接入层,一个 API 覆盖全球四大市场指数。ticker / kline / kline-latest 三层端点提供确定的取值行为——闭市返回什么、字段叫什么、时间戳什么单位,这些是确定的,降级链才敢依赖它们做自动切换。同一套 API Key 鉴权,同一个毫秒 UTC 时钟源,完整文档在 https://docs.tickdb.ai


从 4 个指数到 40,000 个品种时,时区表会变成维护灾难。纯时间戳滑窗——只用 now - timestamp 判断活性——可以零配置覆盖所有资产。

但有一个绕不开的问题:当 now - timestamp 超过阈值,到底是“市场休市”还是“数据源挂了”?两种情况的表象完全相同。

你的监控面板怎么区分这两者? 试试把 stale 标记和 WebSocket 心跳状态做交叉验证——如果你的方案不同,欢迎评论区聊。

posted @ 2026-05-18 16:03  Srena量化员  阅读(38)  评论(0)    收藏  举报