【Python量化系统工程实战 #08】从脚本到生产:量化系统上线 checklist 的最小闭环

0 阅读8分钟

系列完结篇:前 7 篇我们解决了「数据怎么存、自动怎么跑、挂了怎么知道、缺口怎么补」。本篇把脚本变成生产系统的最后 1 公里——环境隔离、日志轮转、数据备份、灰度对比、回滚预案,每一项都是「出事时不至于半夜爬起来」的兜底。

一、从个人脚本到生产系统的差距

很多量化代码跑完回测就上线,结果第一周就出各种事故:

  • 环境:同事用的 Python 3.12,自己机器是 3.9,pandas API 行为不一致 → 报错
  • 日志:脚本跑了两周,logs/app.log 已经 8 GB,tail 命令打不开 → 排错要等 5 分钟
  • 数据:升级版 SQLite schema 后忘了备份,旧版本数据没了 → 重新拉 3 天
  • 灰度:新策略直接替换旧策略,3 天后收益率掉 30% → 不知道该不该回滚
  • 回滚:出问题想回滚,但没提前准备备份,也不知道回滚要多久

上线 checklist 的目标:任何动作都有预演,任何事故都有兜底。

二、本文你将得到什么

  1. 环境隔离自检脚本:5 行检测 venv / 依赖 / 磁盘,避免「在我机器能跑」问题
  2. 日志轮转:1 MB × 5 个文件自动切换,避免日志膨胀
  3. 数据备份:一行 shutil.copy2 给 SQLite 打时间戳快照
  4. 灰度对比:新旧策略并行跑一次,差异阈值报警
  5. 回滚预案: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)

生产备份三重门:

  1. 本地快照(每次升级 / 重大操作前)—— 上节代码
  2. 每日定时全量:cron 0 2 * * * python backup.py,保留 7 天 / 4 周 / 6 月滚动
  3. 异地容灾:每天 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:

  1. 停服务:kill 进程(避免回滚瞬间被新数据污染)
  2. 备份当前状态:放 .pre_rollback.bak 防回滚本身出错
  3. 执行覆盖:shutil.copy2(backup_path, db_path)
  4. 健康检查:连接 DB + 必要表 + 行数对账
  5. 重启服务:先 dry-run,再 open
  6. 监控盯盘:5 / 15 / 60 分钟分阶段确认

九、上线 checklist 操作清单

顺序动作命令 / 代码
1拉最新代码git pull
2创建新 venvpython -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
724h 后对比指标python compare.py
8通过 → 全量切换python run.py --version v2
9失败 → 立即回滚python rollback.py
10写上线报告release_notes.md

十、常见坑

  1. 「直接覆盖生产代码」:永远不要跳过灰度直接换版本。即使是「一行小修改」,都可能引入逻辑变化。
  2. 回滚忘了停服务:回滚瞬间,旧服务的进程还在跑、可能写新数据 → 把回滚覆盖的目标文件又污染了。先 stop、再 copy、再 start。
  3. 备份文件不清理:每天都跑备份,一个月后 1000 个 .bak 文件。保留策略——7 天 / 4 周 / 6 个月滚动,cron 自动删老文件。
  4. 日志没分级:DEBUG / INFO 一起写,生产环境日志几小时内几个 G。生产用 WARNING 级,DEBUG 仅测试环境开。
  5. **「在我机器能跑」**永远是上线前要避免的。Docker 化或 requirements.txt 钉死 + 同一 Python 版本,三者至少占其一。
  6. 没有「回滚预算」:每次上线都准备 30 分钟回滚窗口;超过 30 分钟还没回滚成功 = 二次事故,先应急止血(暂停服务)再继续排查。

十一、小结(系列完结)

整套「数据 → 调度 → 监控 → 一致性 → 补采 → 上线」的工程体系,8 篇走完:

#主题
#01数据存储选型:CSV / SQLite / MySQL
#02APScheduler 自动采集流水线
#03监控告警:异常检测 + 推送
#04数据一致性校验与版本管理
#05多策略并行与进程隔离
#06项目骨架与最小可运行架构
#07历史数据补采与回填
#08上线 checklist(本文)

读者画像升级路径:

  • 看完 #01–#04:从「能跑回测」到「数据自动跑、出了问题能发现」
  • 看完 #05–#07:从「单策略」到「多策略协同、缺口可补」
  • 看完 #08:从「脚本」到「生产系统」—— 上线有 checklist、出问题有兜底

「量化系统的工程能力 = 策略 × 100 后的稳定性」。 同样一套策略,工程差距可以让年化收益 ± 30% 甚至更多。把工程做扎实,才能让策略的预期收益在生产环境真实兑现。


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

代码与文档:github.com/MaiRuiApi