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 心跳状态做交叉验证——如果你的方案不同,欢迎评论区聊。
浙公网安备 33010602011771号