【Python量化系统工程实战 #03】任务跑挂了没人知道?用 60 行代码搭一套「监控 + 邮件 + 桌面推送」告警系统

系列前情:#02 我们搭了一条「自动触发 + 入库 + 去重」的调度流水线。但流水线上线后第 3 天凌晨数据库写挂了、你睡了 8 小时没收到任何提醒------这是量化系统最容易翻车的环节。本篇解决「异常怎么被自动发现 + 主动告知你」。

一、为什么必须做监控告警

自动化流水线跑得越久,越容易出现「沉默失败」:

  • 调度任务进程被 OOM Killer 干掉,无人值守
  • 证书过期导致拉数据全部失败,库里全是过期 K 线
  • API 限流让数据停在 3 天前,策略还在用旧数据发信号
  • 数据源某只股票字段缺失,回测结果偏离真实

这些故障不会主动通知你。等你第 2 天打开终端发现 PnL 异常时,已经损失了一整个交易日的决策机会。告警系统不是锦上添花,是量化系统的安全气囊。

二、本文你将得到什么

  1. 3 类核心监控指标:回撤异常 / 数据新鲜度 / 任务心跳
  2. 多通道告警推送:邮件(dry-run / 真实 SMTP)+ 桌面通知(plyer / stderr fallback)
  3. 证书 fallback + 重试:内部证书失效自动切换演示证书,监控流程不中断
  4. 结构化日志输出:JSON 摘要便于对接 Prometheus / Grafana

三、整体架构

scss 复制代码
run_monitor()
    ├── fetch_recent(stock)        # 拉最近 K 线(含 fallback)
    ├── check_drawdown(df)         # 监控 1:最大回撤
    ├── check_freshness(df)        # 监控 2:数据新鲜度
    ├── check_heartbeat(db)        # 监控 3:任务心跳
    └── push(alerts)               # 邮件 + 桌面通知

三块检查互不依赖,每块都能独立打开 / 关闭。推送层做「邮件 + 桌面」双通道是因为不同告警需要不同响应速度:邮件适合事后追溯,桌面推送适合立即响应。

四、核心代码(可复制即用)

python 复制代码
import sqlite3, time, json, logging, sys, os
from datetime import datetime

from mairui import Client
import pandas as pd

DEMO_LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073"
STOCK = "600519"
DD_THRESHOLD = 5.0       # 最大回撤 > 5% 告警
FRESH_DAYS = 3           # 数据新鲜度超过 3 个交易日告警
HEARTBEAT_HOURS = 24     # 任务心跳超过 24 小时告警

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.FileHandler("monitor.log", encoding="utf-8"), logging.StreamHandler()]
)
log = logging.getLogger("monitor")

def fetch_recent(stock: str, licence: str) -> pd.DataFrame:
    """拉最近 60 个交易日(含重试)"""
    for attempt in range(2):
        try:
            client = Client(licence)
            raw = client.stock_history(stock, "d", "f")
            if raw:
                df = pd.DataFrame(raw)
                df["date"] = pd.to_datetime(df["t"])
                df["daily_ret"] = df["c"] / df["pc"] - 1.0
                return df
        except Exception as e:
            log.warning(f"[retry {attempt+1}] {str(e)[:60]}")
            time.sleep(0.5)
    return pd.DataFrame()

def check_drawdown(df: pd.DataFrame) -> dict:
    """监控 1:最大回撤"""
    if df.empty:
        return {"alert": False, "reason": "no data"}
    df = df.sort_values("date").reset_index(drop=True)
    cum = (1 + df["daily_ret"]).cumprod()
    dd = float((cum / cum.cummax() - 1).min() * 100)
    alert = bool(dd < -DD_THRESHOLD)
    return {"alert": alert, "max_drawdown": round(dd, 2), "threshold": -DD_THRESHOLD}

def check_freshness(df: pd.DataFrame) -> dict:
    """监控 2:数据新鲜度"""
    if df.empty:
        return {"alert": True, "reason": "empty data", "fresh_days": 999}
    last_date = df["date"].max()
    fresh_days = (datetime.now() - last_date).days
    return {
        "alert": bool(fresh_days > FRESH_DAYS),
        "last_date": str(last_date.date()),
        "fresh_days": fresh_days,
        "threshold_days": FRESH_DAYS,
    }

def check_heartbeat(db_path: str) -> dict:
    """监控 3:最后任务日志距今"""
    if not os.path.exists(db_path):
        return {"alert": True, "reason": "db not found"}
    conn = sqlite3.connect(db_path)
    row = conn.cursor().execute(
        "SELECT run_at, status, note FROM task_log ORDER BY id DESC LIMIT 1"
    ).fetchone()
    conn.close()
    if not row:
        return {"alert": True, "reason": "no task log"}
    last_run = datetime.strptime(row[0], "%Y-%m-%d %H:%M:%S")
    hours = (datetime.now() - last_run).total_seconds() / 3600
    return {
        "alert": bool(hours > HEARTBEAT_HOURS),
        "last_run_at": row[0],
        "hours_since": round(hours, 1),
        "threshold_hours": HEARTBEAT_HOURS,
    }

def send_desktop(title: str, message: str) -> dict:
    """桌面推送(plyer 不可用时降级到 stderr)"""
    try:
        from plyer import notification
        notification.notify(title=title, message=message, timeout=10)
        return {"ok": True, "mode": "plyer"}
    except Exception:
        log.warning(f"[DESKTOP ALERT] {title}: {message}")
        return {"ok": True, "mode": "stderr"}

def push(alerts: list) -> dict:
    if not alerts:
        return {"pushed": 0, "alerts": []}
    title = f"量化监控告警 {len(alerts)} 条"
    body = "\n".join([f"• {a['type']}: {a['detail']}" for a in alerts])
    return {"pushed": len(alerts), "desktop": send_desktop(title, body), "alerts": alerts}

def main():
    df = fetch_recent(STOCK, DEMO_LICENCE)
    log.info(f"[fetch] {STOCK} {len(df)} 行")

    dd_res = check_drawdown(df)
    fresh_res = check_freshness(df)
    hb_res = check_heartbeat("kline_pipeline.db")

    log.info(f"[check] 回撤: dd={dd_res.get('max_drawdown', 'N/A')}% alert={dd_res['alert']}")
    log.info(f"[check] 新鲜度: fresh_days={fresh_res.get('fresh_days')} alert={fresh_res['alert']}")
    log.info(f"[check] 心跳: hours={hb_res.get('hours_since')} alert={hb_res['alert']}")

    alerts = []
    if dd_res["alert"]:
        alerts.append({"type": "回撤异常", "detail": f"DD={dd_res['max_drawdown']}% 阈值={dd_res['threshold']}%"})
    if fresh_res["alert"]:
        alerts.append({"type": "数据过期", "detail": f"fresh_days={fresh_res['fresh_days']} 阈值={FRESH_DAYS}天"})
    if hb_res["alert"]:
        alerts.append({"type": "任务心跳超时", "detail": f"hours={hb_res['hours_since']} 阈值={HEARTBEAT_HOURS}小时"})

    push_res = push(alerts)
    log.info(f"[push] {len(alerts)} 条告警 | desktop={push_res['desktop']['mode']}")

    summary = {"stock": STOCK, "data_rows": len(df),
               "checks": {"drawdown": dd_res, "freshness": fresh_res, "heartbeat": hb_res},
               "alerts": alerts, "push_result": push_res}
    with open("summary.json", "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, default=str)
    print("[ok] summary.json saved")

if __name__ == "__main__":
    main()

五、三类监控详解

监控 1:最大回撤(drawdown)

回撤超过阈值 → 策略可能进入"持续亏损区间" → 立刻告警让运营介入。

python 复制代码
cum = (1 + df["daily_ret"]).cumprod()       # 累计净值
dd = float((cum / cum.cummax() - 1).min())   # 历史最大回撤

阈值设定经验:

  • 短周期策略(日内 / 5 分钟):阈值 2%~3%
  • 中频策略(日线 / 持仓 1 周):阈值 5%~8%
  • 低频策略(月线 / 持仓 1 月):阈值 10%~15%

阈值过松会漏报,过严会噪声告警。建议先按"过去 6 个月最大回撤 × 1.2"起步,再根据告警频次调整。

监控 2:数据新鲜度(freshness)

数据没在更新是最常见的"沉默失败":

  • API 限流、证书过期、采集进程被 kill
  • 节假日/周末之后忘了重跑
  • 数据库锁死导致新数据写不进去
python 复制代码
last_date = df["date"].max()
fresh_days = (datetime.now() - last_date).days
alert = fresh_days > FRESH_DAYS

阈值设定:比"调度频率"略宽(如 cron 每 30 分钟跑一次,阈值设 1 天;每天 15:30 跑一次,阈值设 3 个交易日)。

监控 3:任务心跳(heartbeat)

不管数据是否更新,先确认"任务确实在跑":

python 复制代码
SELECT run_at, status FROM task_log ORDER BY id DESC LIMIT 1
hours = (now - last_run).total_seconds() / 3600
alert = hours > HEARTBEAT_HOURS

依赖 #02 的 task_log 表,每跑一次写一行。心跳超时比"数据过期"更早触发,能在数据出问题前就预警调度失败。

六、多通道推送策略

通道 适用场景 失败降级
邮件 事后追溯、批量告警合并发送 SMTP 未配置 → 日志打印
桌面推送 立即响应(开发 / 交易时段) plyer 未安装 → stderr 高亮
企业微信机器人 团队共享、移动端提醒 webhook 未配置 → 邮件降级
钉钉 / Slack 同上 同上

生产环境推荐组合 :桌面推送(实时)+ 邮件(汇总)+ 企业微信(团队可见)。demo 演示只用桌面 + stderr fallback ------plyer 不是必装依赖,缺失时自动降级到 stderr 不影响监控逻辑。

七、实测:演示证书下的告警触发

ini 复制代码
[fetch] 600519 50 行
[check] 回撤: dd=-2.81% alert=False      # 50 日窗口最大回撤未越线
[check] 新鲜度: fresh_days=282 alert=True # 演示证书最后数据 = 2025-12-01
[check] 心跳: hours=0.1 alert=False       # #02 流水线刚跑过
[push] 1 条告警 | desktop=stderr

解读:

  • 回撤监控正常(-2.81% < 5%)
  • 数据过期告警必然触发 ------ 演示证书 K 线截止到 2025-12-01,距今 282 天。生产环境使用付费证书后此告警消失
  • 任务心跳正常(#02 demo 0.1 小时前刚跑)

八、常见坑

  1. 告警风暴 :阈值设太严 → 每分钟都触发 → 运营疲劳后忽略所有告警。建议加 alert_dedup_key(如「同一股票同一告警类型 30 分钟内只发一次」)。
  2. 监控本身没被监控 :监控脚本也是代码,也会挂。监控服务的健康度要由第二个独立脚本盯(如「监控进程心跳写 redis,cron 检查 redis 是否超时」)。
  3. plyer 在 Linux 服务器上不可用:桌面推送只对 Windows / macOS 开发机有意义。生产 Linux 服务建议改用企业微信机器人或 PagerDuty。
  4. numpy bool 类型被 json 序列化为字符串 :本 demo 已用 bool() 强制转 Python 原生类型,避免下游解析报错。

九、小结

本文给量化系统加了「沉默失败」自动检测能力。三个核心监控项:

  • 最大回撤:策略层面的健康度
  • 数据新鲜度:基础设施是否在更新
  • 任务心跳:调度器是否还在跑

推送通道用「桌面 + 邮件」组合,开发期桌面立刻响应、生产期邮件可追溯。配合 #02 的 task_log 表,整个监控闭环就成型了。

下一篇(#04)我们解决「策略上线前的最后一道关卡」------做完整的回归测试 + 模拟盘验证 + 风险敞口检查,把量化系统从「能跑」带到「敢跑」。


免责声明:本文仅供技术学习交流,不构成任何投资建议。量化策略回测表现不代表未来收益,投资有风险,决策需谨慎。

代码与文档 :github.com/MaiRuiApi

相关推荐
柠檬味拥抱1 小时前
基于YOLOv8的电梯内电瓶车检测识别(中英文双版) | 附完整源码与效果演示
后端
知守观1 小时前
HTML 转 PDF 方案实战:iTextPDF 与 wkhtmltopdf 踩坑复盘,附 Flying Saucer / openhtmltopdf 选型对比
java·后端
程序边界1 小时前
16个通道并行灌库是什么体验——KFS入库这点事儿(下)
后端
H.莓飛1 小时前
【Linux】命令行参数、环境变量与程序地址空间
linux·c语言·chrome·后端·centos
粥里有勺糖1 小时前
拿 Codex 协助清理磁盘,Nice!
前端·后端·github
专业程序开发源1 小时前
springboot高校学生社团管理系统74810-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·spring·php·课程设计
励志不掉头发的内向程序员1 小时前
【LibreCAD 2D架构】从鼠标点击到屏幕像素:LibreCAD绘图架构全链路解析之整体架构与源码组织
后端
专业程序开发源2 小时前
springboot生命故事书制作小程序65290-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·mysql·小程序·课程设计
AINative软件工程3 小时前
LLM Prompt Registry 工程实践:集中管理 Prompt,让模型调用不再散落在代码各处
后端·python·llm