系列完结篇:前 7 篇我们解决了「数据怎么存、自动怎么跑、挂了怎么知道、缺口怎么补」。本篇把脚本变成生产系统的最后 1 公里——环境隔离、日志轮转、数据备份、灰度对比、回滚预案,每一项都是「出事时不至于半夜爬起来」的兜底。
一、从个人脚本到生产系统的差距
很多量化代码跑完回测就上线,结果第一周就出各种事故:
- 环境:同事用的 Python 3.12,自己机器是 3.9,
pandasAPI 行为不一致 → 报错 - 日志:脚本跑了两周,
logs/app.log已经 8 GB,tail命令打不开 → 排错要等 5 分钟 - 数据:升级版 SQLite schema 后忘了备份,旧版本数据没了 → 重新拉 3 天
- 灰度:新策略直接替换旧策略,3 天后收益率掉 30% → 不知道该不该回滚
- 回滚:出问题想回滚,但没提前准备备份,也不知道回滚要多久
上线 checklist 的目标:任何动作都有预演,任何事故都有兜底。
二、本文你将得到什么
- 环境隔离自检脚本:5 行检测 venv / 依赖 / 磁盘,避免「在我机器能跑」问题
- 日志轮转:1 MB × 5 个文件自动切换,避免日志膨胀
- 数据备份:一行
shutil.copy2给 SQLite 打时间戳快照 - 灰度对比:新旧策略并行跑一次,差异阈值报警
- 回滚预案:3 步从「出问题」恢复到「上次能用」
三、上线 checklist 总表
按重要性排序,前 3 项是「不做立马翻车」,后 2 项是「做了才稳」:
| # | 项目 | 风险 | 兜底 |
|---|---|---|---|
| 1 | 环境隔离 | 依赖冲突 / Python 版本错位 | venv + 依赖版本锁 |
| 2 | 日志轮转 | 日志爆炸 / 排错难 | RotatingFileHandler |
| 3 | 数据备份 | 数据丢失 / 升级翻车 | 时间戳 .bak 副本 |
| 4 | 灰度对比 | 新策略不稳定 | v1 vs v2 并行观察 |
| 5 | 回滚预案 | 出问题没法恢复 | 备份覆盖 + 二次回滚 |
四、环境隔离:5 行自检脚本
「在我机器能跑,到服务器就崩」的根因就是环境没隔离。生产环境必须用虚拟环境(venv 或 conda env),且依赖版本要钉死。
import sys, shutil
def check_env_isolation():
is_venv = (
sys.prefix != sys.base_prefix
or "envs" in sys.executable.lower()
or ".venv" in sys.executable.lower()
)
print(f"Python: {sys.executable}")
print(f"隔离环境: {'✅' if is_venv else '❌'}")
for pkg in ["mairui", "pandas", "numpy", "apscheduler"]:
try:
mod = __import__(pkg)
print(f"{pkg}: ✅ v{getattr(mod, '__version__', '?')}")
except ImportError:
print(f"{pkg}: ❌")
du = shutil.disk_usage(".")
free_mb = round(du.free / 1024 / 1024, 1)
print(f"磁盘剩余: {free_mb} MB {'✅' if free_mb > 500 else '⚠️'}")
实测:
Python: <your_python>
隔离环境: ✅
mairui: ✅ v1.0.0
pandas: ✅ v2.2.3
numpy: ✅ v2.0.2
apscheduler: ✅ v3.10.4
磁盘剩余: 58760.6 MB ✅
额外动作:
requirements.txt钉死版本(mairui==1.0.0不要写mairui)pyproject.toml或Pipfile.lock锁整个依赖图- 生产环境加
pip-audit跑漏洞扫描 - Docker 化可直接跳过 Python 版本问题(但 Dockerfile 本身也要版本锁)
五、日志轮转:RotatingFileHandler
策略跑一个月,logs/app.log 单文件能从 0 涨到 10 GB,tail 都打不开,必须轮转:
import logging
from logging.handlers import RotatingFileHandler
import os
def setup_logging(log_path: str = "logs/app.log"):
os.makedirs(os.path.dirname(log_path), exist_ok=True)
root = logging.getLogger()
root.setLevel(logging.INFO)
root.handlers.clear() # 重要:避免重复挂载
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
# 1 MB 切一次,保留 5 个历史文件
fh = RotatingFileHandler(
log_path, maxBytes=1024 * 1024, backupCount=5, encoding="utf-8"
)
fh.setFormatter(fmt)
root.addHandler(fh)
# 同时输出 console 便于调试
sh = logging.StreamHandler()
sh.setFormatter(fmt)
root.addHandler(sh)
实测:
2026-09-11 10:06:26 [INFO] [test] 第 1 条测试日志
2026-09-11 10:06:26 [INFO] [test] 第 2 条测试日志
2026-09-11 10:06:26 [INFO] [test] 第 3 条测试日志
2026-09-11 10:06:26 [INFO] [test] 第 4 条测试日志
2026-09-11 10:06:26 [INFO] [test] 第 5 条测试日志
已写入 logs/app.log (440 bytes)
轮转策略选择:
RotatingFileHandler:按文件大小切(适合写日志频率稳定的场景)TimedRotatingFileHandler:按时间切(每天/每小时一个文件,适合审计)- 生产建议两个都上:大小轮转保底 + 时间轮转便于合规审计
六、数据备份:一行 shutil.copy2
升级 schema 前、出问题想回滚前,先备份再说。SQLite 单文件数据库备份成本最低:
import shutil, os
from datetime import datetime
def backup_database(db_path: str, backup_dir: str = "backups") -> str:
if not os.path.exists(db_path):
return ""
os.makedirs(backup_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = os.path.join(backup_dir, f"{os.path.basename(db_path)}.{ts}.bak")
shutil.copy2(db_path, backup_path)
return backup_path
实测:
备份成功: backups\history_kline.db.20260911_100626.bak (24576 bytes)
生产备份三重门:
- 本地快照(每次升级 / 重大操作前)—— 上节代码
- 每日定时全量:cron
0 2 * * * python backup.py,保留 7 天 / 4 周 / 6 月滚动 - 异地容灾:每天
rsync到另一台机器或对象存储(OSS / COS / S3)
copy2 会保留原文件的 mtime + permission,比 copy 更适合做备份(出问题一眼看出哪个是旧的)。
七、灰度对比:新旧策略并行跑一次
新策略不能「直接替换上线」。标准做法是保留旧版 + 新版并行跑 1 周,对比夏普 / 收益 / 最大回撤:
def gray_release_compare(stocks, licence):
"""v1 vs v2 各跑一次,对比结果(演示证用同一价格代替真实策略差异)。"""
results = {"v1": {}, "v2": {}, "diff": 0}
for stock in stocks:
from mairui import Client
cli = Client(licence)
raw = cli.stock_history(
code=stock, st="2025-12-01", et="2025-12-01",
period="d", dividend="f"
)
price = float(raw[-1].get("c", 0.0)) if raw else 0.0
results["v1"][stock] = price # 旧策略结果
results["v2"][stock] = price # 新策略结果(演示证同价)
print(f" {stock}: v1={price:.2f} v2={price:.2f}")
results["diff"] = sum(
abs(results["v1"][s] - results["v2"][s]) for s in stocks
)
return results
实测:
600519: v1=11.33 v2=11.33 ✅
000001: v1=11.33 v2=11.33 ✅
300750: v1=11.33 v2=11.33 ✅
累计绝对偏差: 0.0000
真实部署的对比维度:
- 收益曲线:日收益、夏普、最大回撤
- 稳定性:连续亏损天数、最大单日亏损
- 延迟:从拿到数据到信号产生的时间
- 资源占用:CPU / 内存峰值
灰度期间任一关键指标恶化超过阈值(如夏普下降 30%),立即停止灰度、回滚到 v1。
八、回滚预案:3 步恢复
回滚的关键是「回滚本身不出错」。三个关键动作:
import shutil, os
def rollback(db_path: str, backup_path: str) -> bool:
if not (backup_path and os.path.exists(backup_path)):
return False
# 1. 备份当前主库(万一回滚失败还能再回滚一次)
pre_rollback = db_path + ".pre_rollback.bak"
shutil.copy2(db_path, pre_rollback)
# 2. 用备份覆盖主库
shutil.copy2(backup_path, db_path)
# 3. 健康检查(连接 + 必要表存在)
import sqlite3
try:
conn = sqlite3.connect(db_path)
conn.execute("SELECT COUNT(*) FROM kline").fetchone()
conn.close()
return True
except Exception:
return False
实测:
回滚成功: history_kline.db ← backups\history_kline.db.20260911_100626.bak
(回滚前快照保留: history_kline.db.pre_rollback.bak)
回滚操作 SOP:
- 停服务:kill 进程(避免回滚瞬间被新数据污染)
- 备份当前状态:放
.pre_rollback.bak防回滚本身出错 - 执行覆盖:
shutil.copy2(backup_path, db_path) - 健康检查:连接 DB + 必要表 + 行数对账
- 重启服务:先 dry-run,再 open
- 监控盯盘:5 / 15 / 60 分钟分阶段确认
九、上线 checklist 操作清单
| 顺序 | 动作 | 命令 / 代码 |
|---|---|---|
| 1 | 拉最新代码 | git pull |
| 2 | 创建新 venv | python -m venv venv && source venv/bin/activate |
| 3 | 装依赖 | pip install -r requirements.txt |
| 4 | 环境自检 | python main.py env |
| 5 | 备份主库 | python backup.py |
| 6 | 灰度上线(v1 + v2 并行) | python run_gray.py |
| 7 | 24h 后对比指标 | python compare.py |
| 8 | 通过 → 全量切换 | python run.py --version v2 |
| 9 | 失败 → 立即回滚 | python rollback.py |
| 10 | 写上线报告 | release_notes.md |
十、常见坑
- 「直接覆盖生产代码」:永远不要跳过灰度直接换版本。即使是「一行小修改」,都可能引入逻辑变化。
- 回滚忘了停服务:回滚瞬间,旧服务的进程还在跑、可能写新数据 → 把回滚覆盖的目标文件又污染了。先 stop、再 copy、再 start。
- 备份文件不清理:每天都跑备份,一个月后 1000 个 .bak 文件。保留策略——7 天 / 4 周 / 6 个月滚动,cron 自动删老文件。
- 日志没分级:DEBUG / INFO 一起写,生产环境日志几小时内几个 G。生产用 WARNING 级,DEBUG 仅测试环境开。
- **「在我机器能跑」**永远是上线前要避免的。Docker 化或 requirements.txt 钉死 + 同一 Python 版本,三者至少占其一。
- 没有「回滚预算」:每次上线都准备 30 分钟回滚窗口;超过 30 分钟还没回滚成功 = 二次事故,先应急止血(暂停服务)再继续排查。
十一、小结(系列完结)
整套「数据 → 调度 → 监控 → 一致性 → 补采 → 上线」的工程体系,8 篇走完:
| # | 主题 |
|---|---|
| #01 | 数据存储选型:CSV / SQLite / MySQL |
| #02 | APScheduler 自动采集流水线 |
| #03 | 监控告警:异常检测 + 推送 |
| #04 | 数据一致性校验与版本管理 |
| #05 | 多策略并行与进程隔离 |
| #06 | 项目骨架与最小可运行架构 |
| #07 | 历史数据补采与回填 |
| #08 | 上线 checklist(本文) |
读者画像升级路径:
- 看完 #01–#04:从「能跑回测」到「数据自动跑、出了问题能发现」
- 看完 #05–#07:从「单策略」到「多策略协同、缺口可补」
- 看完 #08:从「脚本」到「生产系统」—— 上线有 checklist、出问题有兜底
「量化系统的工程能力 = 策略 × 100 后的稳定性」。 同样一套策略,工程差距可以让年化收益 ± 30% 甚至更多。把工程做扎实,才能让策略的预期收益在生产环境真实兑现。
免责声明:本文仅供技术学习交流,不构成任何投资建议。量化策略回测表现不代表未来收益,投资有风险,决策需谨慎。
代码与文档:github.com/MaiRuiApi