上一篇的数据管道让行情自动更新了,但「自动」带来新问题:没有人的地方出了错,谁告诉你? 计划任务凌晨挂了、接口改版拉到空数据、策略信号今天该发没发——这些都是静默失败。本篇给整个系统装上「神经系统」:规范的日志(发生了什么)、异常处理(出错怎么办)、心跳与告警(出事了喊人)。
本篇目标
- 能用 logging 模块替代 print:分级、格式化、同时输出到屏幕和文件
- 能对策略运行中的异常分类处理:数据缺失、接口报错、下单失败各有对策
- 能实现 retry(重试)与降级(fallback)两种容错模式
- 能实现心跳机制:任务「定期报平安」,断了平安信就是告警
- 能设计一张每日运行状态表,一眼看清系统健康度
核心概念
为什么 print 不配进生产
print 的问题:没有时间戳(不知道何时发生)、没有级别(重要与琐碎混在一起)、不落盘(控制台一关全消失)、不能配置(想只看 ERROR 以上做不到)。logging 模块四件套解决全部:Logger(记录入口)、Handler(去哪里:屏幕/文件)、Formatter(长什么样)、Level(多重要:DEBUG < INFO < WARNING < ERROR < CRITICAL)。开发期用 DEBUG 看细节,生产期调到 INFO,排障时临时放开——一个配置项切换视角。
异常分类:三种错误三种对策
| 异常类型 | 例子 | 对策 |
|---|---|---|
| 瞬时性 | 网络超时、接口限流 | 重试:退避间隔后再试几次 |
| 环境性 | 接口改版、依赖缺失 | 降级:换备用数据源或跳过本轮 |
| 逻辑性 | 数据为空、信号越界 | 止损停止:立即告警,等人来 |
分类的价值在于不把所有错误一锅端。瞬时错误重试三次就好了,不该半夜叫醒你;逻辑错误说明系统出了真问题,重试一百次也没用。把「重试次数耗尽」升级为告警,把「降级发生」记 WARNING 供复盘。
心跳与告警
心跳(Heartbeat)的思路反过来想:不监测所有故障(枚举不完),只监测「活着」这一个信号。任务每次正常跑完,往 logs/heartbeat.txt 写一行时间戳。再看门狗(Watchdog)脚本定期检查:时间戳距今超过阈值(比如 26 小时),说明任务已死——没收到平安信本身就是最大的告警。
教学环境用「写状态文件 + 控制台弹窗」模拟告警;真实告警可以接邮件、企业微信机器人、Server酱等推送渠道,原理完全相同:把「异常文本」POST 到一个 webhook 地址。
划重点:监控的目的不是「不出错」,而是出错后知道得多快。一个平均 10 分钟发现故障的系统,比一个「从不报错但坏了三天没人知道」的系统可靠一百倍。能自动恢复的让它自动恢复(重试/降级),不能恢复的尽快喊人(告警),其余交给日志留痕。
完整代码
本篇依赖:uv pip install pandas numpy(标准库 logging 不用装)。
# ---------- 1. logging 四件套:一次配置,全程复用 ----------
import logging
from pathlib import Path
def setup_logger(name="quant", log_file="logs/run.log"):
Path("logs").mkdir(exist_ok=True)
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG) # 入口放开到DEBUG
if logger.handlers: # 防重复添加handler
return logger
fmt = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S")
fh = logging.FileHandler(log_file, encoding="utf-8")
fh.setLevel(logging.DEBUG) # 文件记全量
sh = logging.StreamHandler()
sh.setLevel(logging.INFO) # 屏幕只看INFO以上
fh.setFormatter(fmt); sh.setFormatter(fmt)
logger.addHandler(fh); logger.addHandler(sh)
return logger
log = setup_logger()
# ---------- 2. 异常分类处理:重试 + 降级 ----------
import time
import numpy as np
import pandas as pd
class TransientError(Exception): # 瞬时性:值得重试
pass
def fetch_with_retry(fetch_fn, retries=3, backoff=2.0, tag="data"):
"""带指数退避的重试:第n次等 backoff*n 秒"""
for attempt in range(1, retries + 1):
try:
return fetch_fn()
except TransientError as e:
if attempt == retries:
log.error(f"[{tag}] 重试{retries}次仍失败: {e}")
raise
wait = backoff * attempt
log.warning(f"[{tag}] 第{attempt}次失败, {wait:.0f}s后重试: {e}")
time.sleep(wait)
def get_price_with_monitoring(code):
"""拉行情:瞬时错误重试,环境错误降级到本地缓存"""
try:
# 模拟一个三成概率超时的接口(教学用,真实场景替换为akshare)
if np.random.default_rng().random() < 0.3:
raise TransientError("接口超时(模拟)")
return pd.Series({"600519": 1520.0}) # 模拟返回
except TransientError:
return fetch_with_retry(
lambda: pd.Series({"600519": 1520.0}),
tag=f"price:{code}")
except Exception as e: # 环境性:降级
log.warning(f"[price:{code}] 接口异常,降级读本地缓存: {e}")
cache = Path("data/raw") / f"{code}.parquet"
if cache.exists():
df = pd.read_parquet(cache)
return df["close"].iloc[-1:]
log.error(f"[price:{code}] 本地缓存也不存在!")
return None
# ---------- 3. 心跳与看门狗 ----------
from datetime import datetime, timedelta
HEARTBEAT_FILE = Path("logs/heartbeat.txt")
def write_heartbeat(task_name="daily_update"):
"""任务正常结束时调用:报平安"""
HEARTBEAT_FILE.parent.mkdir(exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
HEARTBEAT_FILE.write_text(f"{ts}|{task_name}", encoding="utf-8")
log.info(f"心跳已写入: {ts} ({task_name})")
def check_heartbeat(max_silence_hours=26):
"""看门狗:超时未报平安 → 告警"""
if not HEARTBEAT_FILE.exists():
return "CRITICAL: 心跳文件不存在——任务从未成功运行过"
ts_str, task = HEARTBEAT_FILE.read_text(
encoding="utf-8").strip().split("|")
last = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
silent = datetime.now() - last
if silent > timedelta(hours=max_silence_hours):
return f"CRITICAL: {task} 已静默 {silent}, 任务疑似挂死"
return f"OK: {task} 上次心跳 {ts_str}"
def alert(message):
"""教学告警:写文件 + 弹控制台响铃。真实环境换webhook推送"""
with open("logs/alerts.log", "a", encoding="utf-8") as f:
f.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} {message}\n")
print(f"\a>>> 告警: {message}")
# ---------- 4. 每日运行状态表 ----------
def run_daily_job():
"""每日任务的完整监控骨架"""
status = {"date": datetime.now().strftime("%Y-%m-%d"),
"data": "-", "signal": "-", "error": ""}
try:
px = get_price_with_monitoring("600519")
if px is None:
status["data"] = "FAIL"
alert("行情获取失败且无缓存")
else:
status["data"] = "OK"
log.info(f"行情获取成功: {px.iloc[0]}")
# ... 策略信号计算(略,见第50篇整合)...
status["signal"] = "OK"
write_heartbeat("daily_job") # 全程无异常 → 报平安
except Exception as e:
status["error"] = str(e)[:80]
status["signal"] = "FAIL"
alert(f"每日任务异常终止: {e}")
# 状态追加到CSV,积累成健康度档案
row = pd.DataFrame([status])
status_file = Path("logs/daily_status.csv")
row.to_csv(status_file, mode="a", header=not status_file.exists(),
index=False, encoding="utf-8")
return status
status = run_daily_job()
print(status)
print(check_heartbeat())
代码逐段解释
- setup_logger:四件套标准配置——文件 Handler 记 DEBUG 全量(排障用),屏幕 Handler 只放 INFO 以上(清爽)。
if logger.handlers: return防止 notebook 里重复执行时 handler 翻倍(日志重复打印的经典坑)。 - 重试与降级:
fetch_with_retry用指数退避(2s、4s、6s),只捕TransientError——重试只对瞬时错误有意义,其他异常立刻抛出。get_price_with_monitoring是分类处理的完整示范:模拟三成概率超时触发重试;其他异常降级读本地缓存;缓存也没有就 ERROR 级告警。 - 心跳与看门狗:
write_heartbeat在任务成功走完时写时间戳——注意位置在 try 的末尾,中途崩掉不会误报平安。check_heartbeat是被动的看门狗:静默超过 26 小时(容忍一个周末)判 CRITICAL。alert教学版用文件+响铃,换成一发 requests.post 就是真实推送。 - 状态表:
daily_status.csv每天追加一行(date / data / signal / error),一个月后这张表就是系统的「体检档案」——哪天开始频繁 FAIL、错误信息是什么,一目了然。header=not exists处理首行表头,append 模式天然幂等。
示例输出(示意):
2026-09-28 09:30:00 [INFO] quant - 行情获取成功: 1520.0
2026-09-28 09:30:00 [INFO] quant - 心跳已写入: 2026-09-28 09:30:00 (daily_job)
{'date': '2026-09-28', 'data': 'OK', 'signal': 'OK', 'error': ''}
OK: daily_job 上次心跳 2026-09-28 09:30:00
(当接口连续超时时,会看到:)
2026-09-28 09:30:04 [WARNING] quant - [price:600519] 第1次失败, 2s后重试: 接口超时(模拟)
2026-09-28 09:30:08 [WARNING] quant - [price:600519] 第2次失败, 4s后重试: 接口超时(模拟)
常见坑与小练习
常见坑
- logger 重复配置:脚本被 import 两次或 notebook 重跑,handler 加了两套,每条日志打两遍。
if logger.handlers: return这行保险丝必备。 - except Exception 一把抓:所有异常统一「捕获+continue」,瞬时错误没重试、逻辑错误没告警,系统看起来很稳其实在带病运行。先分类再处理。
- 心跳写在 try 开头:任务还没干活就报了平安,挂了也查不出来。心跳必须在成功完成全部步骤后写。
- 告警风暴:看门狗每分钟检查一次、每次都告警,一晚上几百条推送把自己练成「狼来了」。告警要带冷却(同类告警 1 小时内只发一次)。
- 日志无轮转:
FileHandler单文件无限追加,一年后几个 GB。用RotatingFileHandler(maxBytes=10*1024*1024, backupCount=5)自动滚动。
小练习
- 把
FileHandler换成RotatingFileHandler(单文件 10MB、留 5 个备份),并故意写一个循环刷 10 万条日志观察文件滚动。思路提示:
from logging.handlers import RotatingFileHandler,替换 setup_logger 里的 fh 即可。 - 给
alert加冷却机制:同类告警 60 秒内只发一次(提示:把最近告警的「消息+时间」存字典,发前比对)。思路提示:模块级字典
_last_alert = {},key 用消息哈希或前 20 字符,value 存时间戳。 - 把
alert改造成真实推送版:向一个 webhook 地址 POST JSON(可用公网测试站点如 httpbin 的 /post 端点演练)。思路提示:
requests.post(url, json={"text": message}, timeout=5),失败时降级写本地文件——告警通道本身也要容错。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。