Python 全球指数监控面板:TickDB + REST + WebSocket 完整方案

目录


一、凌晨两点半,监控屏上的数字不动了

去年冬天凌晨 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 == 0last_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 的恒指,走路径 Bclose → 前端显示"已收市 | 最近收盘价 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
相关推荐
啊哈哈121381 小时前
系统设计复盘:为什么 Agent 的 ReAct 循环必须内嵌确定性保护层——以 FitMind 健康助手的路由与步骤控制为例
人工智能·python·react
一颗牙牙2 小时前
安装mmcv
开发语言·python·深度学习
大数据魔法师3 小时前
Streamlit(二)- Streamlit 架构与运行机制
python·web
m0_470857643 小时前
PHP怎么实现工厂模式_Factory模式编写指南【指南】
jvm·数据库·python
大数据魔法师3 小时前
Streamlit(三)- Streamlit 多页面应用开发
python·web
我的xiaodoujiao3 小时前
API 接口自动化测试详细图文教程学习系列20--结合Pytest框架使用
python·学习·测试工具·pytest
python在学ing3 小时前
前端-CSS学习笔记
前端·css·python·学习
IT策士4 小时前
Django 从 0 到 1 打造完整电商平台:为什么用 Django 做电商?
后端·python·django
zkkkkkkkkkkkkk5 小时前
Linux进行管理工具Supervisor配置与使用
linux·python·supervisor