从零搭建你的 A 股量化系统(二十四):配置管理与日志系统:出问题时能查得到

系列名:《从零搭建你的 A 股量化系统》| 专栏 S2 Python 工具链 | 第 12 篇 / 共 14 篇

标签:#量化投资 #工程实践 #配置管理 #日志系统 #Python #量化系统 #A股 #散户 #pydantic

上篇 S2-023 我们给了"可直接 clone 的工程骨架"------config/ data/ src(8包) scripts/ notebooks/ tests/ logs/ results/,并把 S2-013~022 的产物各就各位。但骨架有两个格子还是空的config/settings.yaml 只是样例、src/utils/logger.py 还没写。

前面 S2-021 的"每日 runner"还在用 open(log_path, "a").write(...) 自己写日志、print() 打关键结果。它在"手动跑"时凑合,一旦交给 Windows 任务计划程序每天凌晨自动跑,出事了你连"昨晚到底跑没跑、崩在哪一行"都查不到------因为定时任务没有可见窗口,日志是你唯一的眼睛。

这篇就是把这两块砖砌上:src/utils/config.py(pydantic 校验)让配置"可管、可验、可归因",用 src/utils/logger.py(分级 + 滚动落盘)让出事"查得到、查得快" 。配套 src/s2_024_demo.py 已用托管 Python 3.13 实跑通过。

一句话定位:S2-023 让你"从第一天起长在正确的地基上",S2-024 让你"地基里装好仪表盘和黑匣子"------配置是仪表盘,日志是黑匣子,两者一起决定你半夜被叫醒时能不能 5 分钟定位问题。


📌 你将学到

  • 为什么"配置写死在代码里 + 用 print 调试"是量化项目最隐蔽的工程债(比算错因子更致命);
  • pydantic 把 YAML 变成强类型对象:读错类型、超范围、多打字母,启动期就报错,而不是运行时悄悄错;
  • 三条配置防线extra="forbid" 防 typo、gt/le 边界校验拦离谱参数、version 必填做"版本归因";
  • 密钥绝不进日志mask_secret() 打码 + secrets.yaml.gitignore 排除(延续 S2-022 的"密钥不进库");
  • 从 print 到专业日志get_logger 工厂 + 控制台/滚动文件双出口(RotatingFileHandlerlogs/UTF-8、幂等防重复 handler);
  • 上下文绑定 :用 LoggerAdapter 让每条日志都带 run_id/strategy,出问题时按"哪次运行、哪个策略"精准检索;
  • 怎么把这两个模块装回 S2-023 的骨架,并把 S2-021 的 runner 改造成"专业日志版";
  • 五个常见踩坑与排雷(含"单文件日志长到几十 GB"这种磁盘杀手)。

📖 前置知识

  • 已按 S2-023 生成工程骨架,知道 config/settings.yamlsrc/utils/ 各自住哪;
  • 已按 S2-021 让脚本每天自动跑,知道"定时任务没有可见窗口,日志与心跳是唯一证据";
  • 已按 S2-022 上了 Git,知道 secrets.yaml.gitignore 排除、真实密钥永不进库;
  • 装好依赖:pip install pydantic pyyaml(本篇语法基于 pydantic v2 ;S2-023 的 pyproject.toml 里补一行即可);
  • 看过《04-A股散户量化系统架构-Windows.md》§4.3"版本归因"------本篇的 version 必填规则就是它的落地。

先说结论

散户量化项目里,"配置散在代码里改不动、出错了日志查不到"是比"算错一个因子"更致命的工程债------前者让你连问题都发现不了,后者至少结果不对你还能察觉。

红线 违反后的典型惨状 正确做法
配置写死在 .py 换参数要改代码、易误提交密钥、无法做版本归因 config/*.yaml 分离 + pydantic 校验
print 调试、无日志文件 定时任务崩了查不到、中文乱码、无级别难筛 分级 logger + 滚动落盘 logs/
把 token 打进日志 密钥泄露、别人刷你的接口额度 mask_secret + secrets.yaml 被 gitignore
配置里多打个字母 静默忽略、跑出离谱结果几天后才发现 extra="forbid",启动即报错
单文件日志无限增长 日志几十 GB、磁盘写满、排查翻不动 RotatingFileHandler 按大小切割

一言以蔽之 :配置是给系统看的"仪表盘",日志是给未来自己看的"黑匣子"------你今天省下的每一行配置校验和每一条分级日志,都会变成半夜被报警叫醒后多花的一个小时。


一、为什么配置和日志是"地基的最后两块砖"

S2-023 的骨架里,config/settings.yaml 是个样例 :路径、数据源、调度、风控默认值都填好了,但它是"死的"------你读它靠 yaml.safe_load 拿到一个 dict,之后全靠你自己记,例如"这个键叫 risk.max_single_position、它应该是 0~1 之间的浮点数"。

问题在于:dict 不会帮你记任何事 。你写成 max_single_position: 150(把百分比当成了小数),Python 不会报错,策略照跑,直到三个月后回测发现"单票仓位 150%、组合爆仓"才回头查------而那时你已经分不清是参数错了还是逻辑错了。

日志那边更隐蔽。S2-021 的 runner 用:

python 复制代码
with open(log_path, "a", encoding="utf-8") as log:
    log.write(f"...{line}\n")

它能写文件,但没有级别 (INFO/WARNING/ERROR 混在一起,想筛 ERROR 只能肉眼翻)、没有上下文 (哪次运行、哪个策略?不知道)、没有滚动(跑三年就是一个几百 MB 的单文件)。而定时任务一旦失败,你手里只有这一个文件------它要么太乱、要么太大、要么根本没告诉你"为什么挂"。

💡 三原则之一:"回测可信度优先" 。可信度不只来自算法正确,也来自"当结果不对时,你能立刻还原当时跑的是哪版代码、哪套参数、哪次运行"。配置做归因 + 日志留黑匣子,是可信度的两条腿。


二、配置管理:从"硬编码"到"类型安全"

核心思想:把"裸 YAML dict"换成"pydantic 校验过的强类型对象"。读配置三步走:

text 复制代码
config/settings.yaml ──yaml.safe_load──▶ dict ──Settings.model_validate──▶ 强类型对象
                                                  │
                                                  ├─ 类型不对?立刻报错
                                                  ├─ 范围越界?立刻报错
                                                  └─ 多打字母?立刻报错(extra=forbid)

拿到的是 cfg.settings.risk.max_single_position(一个 float,不是"可能拼错的字符串"),IDE 能补全、mypy 能查、运行时不会静默错。

为什么用 pydantic 而不是自己写一堆 if

  • 自己写校验:20 个字段 × 3 种检查 = 60 行 if,还容易漏;
  • pydantic:字段声明即校验,Field(gt=0, le=1) 一行搞定,报错信息还自带"哪个字段、为什么错"

⚠️ 本篇只把 pydantic 当"配置校验器"用 ------聚焦 YAML → 强类型对象这一件事。正式的"类型系统全身用法"(type hints 铺满全项目、给订单/持仓建 domain model)是 S2-025 的主题,本篇先埋好这块砖,下篇我们再把它砌成墙。


三、config.py 实战:四层模型 + 三条防线

下面就是放进 src/utils/config.py 的生产代码骨架。先建一个"禁止多余字段"的基类,再按 S2-023 的 settings.yaml 四段拆成四个子模型:

python 复制代码
from pydantic import BaseModel, ConfigDict, Field, field_validator
import yaml

# 所有配置模型默认"禁止多余字段"------配置里多打一个字母直接报错
class _Strict(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=False)

class RiskConfig(_Strict):
    max_single_position: float = Field(0.25, gt=0, le=1)   # 单票 0~1
    max_single_industry: float = Field(0.30, gt=0, le=1)   # 单行业 0~1
    max_order_lines_per_day: int = Field(20, ge=1)         # 单日清单笔数

class ScheduleConfig(_Strict):
    update_data_at: str = "16:00"
    generate_orders_at: str = "08:45"
    skip_non_trading_day: bool = True
    # 时间格式校验:HH:MM,写错一笔就拦
    _check = field_validator("update_data_at", "generate_orders_at")(_valid_hhmm)

class Settings(_Strict):
    paths: PathsConfig = Field(default_factory=PathsConfig)
    data_source: DataSourceConfig = Field(default_factory=DataSourceConfig)
    schedule: ScheduleConfig = Field(default_factory=ScheduleConfig)
    risk: RiskConfig = Field(default_factory=RiskConfig)

三条防线(这是全文精华):

  1. extra="forbid"(防 typo)settings.yaml 里把 max_single_position 拼成 max_single_positon,pydantic 直接 Extra inputs are not permitted,而不是静默忽略。配置文件的"错别字"是最阴险的 bug------它不崩溃,只是悄悄用默认值跑。
  2. 边界校验 gt/le/ge(防离谱)max_single_position: 1.5 这种"单票 150%"在 le=1 面前当场被拒。把业务的硬约束写进类型,比在代码里到处 assert 可靠一万倍。
  3. version 必填(做归因) :策略参数 YAML 必须带 version: 1.0,否则 StrategyConfig 校验失败。每次改参 +0.1,配合 S2-022 的 git tag,三个月后你能精准定位"2026-09-12 那笔亏损"用的是哪版代码 + 哪套参数(落实《04》§4.3)。

编排层 ProjectConfig.load(root) 一次性加载全局配置 + 真实 secrets.yaml(缺失则告警但不崩)+ 所有策略配置,返回强类型门面:

python 复制代码
cfg = ProjectConfig.load("D:/ashare-quant")
cfg.settings.risk.max_single_position     # 0.25(强类型 float,IDE 可补全)
cfg.get_strategy("multi_factor").version  # 1.0(必填,缺了启动就报错)
cfg.mask_secrets()                         # 密钥打码后给日志用,绝不暴露原文

四、坏配置当场现形:两个真实惨案

光说防线太虚,直接跑 src/s2_024_demo.py 的"坏配置"用例(下面是本机实跑输出):

text 复制代码
=== ② 坏配置触发 pydantic 报错(启动期就拦住)===
    [√] 范围越界(单票=1.5): pydantic 拦住 → 1 validation error for Settings
    [√] 多余键(typo): pydantic 拦住 → 1 validation error for Settings
  • 惨案 A------范围越界 :手滑把 max_single_position 写成 1.5(把"150%"当成了小数)。le=1 当场拦截,程序启动即退出,绝不会带着错误参数跑一天
  • 惨案 B------typo 键 :风控段多打了一个 oops_typo_keyextra="forbid" 当场拦截,告诉你"这个键我不认识"。

对比一下"没有校验"的世界:这两种错误都会静默通过 ,你的策略照常运行,直到某天发现组合异常才回头地毯式排查------而那时你早已分不清是这次手滑还是别处逻辑错了。配置校验的价值,就是把"三个月后才发现的惨案"压缩成"启动时一行红字"。


五、密钥管理:永远不进日志

量化项目离不了密钥:Tushare token、钉钉 webhook、邮箱授权码。它们一旦进日志、进 Git,泄露的后果是实打实的------别人拿你的 token 刷接口额度都算轻的。

两道保险("密钥不进库"):

  1. 文件层 :真实 config/secrets.yaml 在 S2-022 的 .gitignore 里就已被排除,只提交 secrets.yaml.example 样例;
  2. 日志层mask_secret() 把值打码后再记录"已配置",而不是原文:
python 复制代码
from src.utils.config import mask_secret
token = "tushare_9f3a2b7c8e1d4f60a5b2c3d4e5f60718"
mask_secret(token)          # "tush...18"(首尾留几位,中间打码)
cfg.mask_secrets()          # {'tushare': {'token': '****'}, 'dingtalk': {'webhook': '****'}, ...}

demo 里加载真实密钥后打印的摘要就是打码态:

text 复制代码
密钥摘要(已打码): {'tushare': {'token': '****'}, 'dingtalk': {'webhook': '****'}, 'email': {'smtp_user': '****', 'smtp_auth_code': '****'}}

⚠️ demo 里写的 secrets.yaml 只存在于系统临时目录 C:\Users\...\Temp\s2_024_demo_project,不是你的项目仓库 ,跑完即弃;你自己的真实 secrets.yaml 永远被 .gitignore 拦在项目外。别把 demo 的临时文件当模板提交。


六、日志系统:从 print 到"分级 + 滚动落盘"

S2-021 的 runner 用 open().write() 自己写日志,能用但有三个硬伤:无级别、无上下文、无滚动 。我们用 src/utils/logger.py 一次性解决。核心是一个幂等的 get_logger 工厂:

python 复制代码
import logging
from logging.handlers import RotatingFileHandler

def get_logger(name="quant", log_dir="logs", level=logging.INFO,
               rotate_mb=5, backups=5, console=True):
    logger = logging.getLogger(name)
    if logger.handlers:           # 幂等:同名重复调用不会挂双份 handler(新手最高频 bug)
        return logger
    logger.setLevel(level)
    logger.propagate = False

    fmt = "%(asctime)s | %(levelname)-7s | %(name)s | [%(run_id)s/%(strategy)s] %(message)s"
    # ① 控制台(人看)
    if console:
        ch = logging.StreamHandler(); ch.setFormatter(logging.Formatter(fmt)); logger.addHandler(ch)
    # ② 滚动文件(黑匣子,落 logs/)
    log_dir = Path(log_dir); log_dir.mkdir(parents=True, exist_ok=True)
    fh = RotatingFileHandler(log_dir / f"{name}.log", maxBytes=rotate_mb*1024*1024,
                             backupCount=backups, encoding="utf-8", delay=True)
    fh.setFormatter(logging.Formatter(fmt)); logger.addHandler(fh)
    return logger

四个设计点(对应 S2-021 的痛点):

  1. 五个级别各司其职DEBUG(排查)/ INFO(正常流水)/ WARNING(可恢复异常,如限流退避)/ ERROR(某标的失败但继续)/ CRITICAL(风控未加载、阻断下单)。再也不用 print 一把梭,想筛 ERROR 一条命令搞定。
  2. 滚动落盘 logs/RotatingFileHandler 单文件到 5MB 自动切备份、保留 5 个,delay=True 首次写才建文件------三年跑下来也不会产生几十 GB 的单文件把磁盘写满
  3. UTF-8 编码:中文日志不乱码(S2-021 已强调过,这里固化进工厂)。
  4. 幂等 :同名 logger 重复 get_logger 不会叠加 handler(否则日志翻倍输出,新手最常踩)。

本机实跑的日志落盘 + 回读(来自 demo):

text 复制代码
=== ③ 日志系统:5 级 + 上下文绑定 → logs/ ===
  日志已落盘: C:\Users\XXXX\AppData\Local\Temp\s2_024_demo_project\logs\s2_024_demo.log
  ── 回读日志文件末尾 4 行 ──
    2026-08-05 07:03:29 | WARNING | s2_024_demo | [20260805/multi_factor] tushare 限流,已自动退避 0.3s
    2026-08-05 07:03:29 | ERROR   | s2_024_demo | [20260805/multi_factor] 下载 600000.SH 失败:连接超时(将重试)
    2026-08-05 07:03:29 | CRITICAL| s2_024_demo | [20260805/multi_factor] 风控模块未加载,已阻断下单!
    2026-08-05 07:03:29 | INFO    | s2_024_demo | [20260805/multi_factor] 已加载 tushare token: tush...18

七、上下文绑定:让每条日志能"按运行/策略检索"

上面日志格式里那对 [%(run_id)s/%(strategy)s] 不是装饰------它是黑匣子的索引。定时任务每天跑、多个策略并行跑,出问题时你要的是"把 2026-08-05 那次 multi_factor 运行的所有 WARNING/ERROR 捞出来",而不是在一锅粥里翻。

LoggerAdapter + Filter 实现"一次绑定、条条带上下文",不用每次手写 extra

python 复制代码
class _ContextAdapter(logging.LoggerAdapter):
    def process(self, msg, kwargs):
        kwargs.setdefault("extra", {})
        kwargs["extra"] = {**self.extra, **kwargs["extra"]}
        return msg, kwargs

def bind_context(logger, **ctx):
    return _ContextAdapter(logger, ctx)

# 用法:一次绑定,之后每条日志自动带 run_id / strategy
log = bind_context(get_logger("daily", log_dir="logs", level=logging.DEBUG),
                   run_id="20260805", strategy="multi_factor")
log.info("每日任务启动,加载配置完成")   # 自动带 [20260805/multi_factor]

排查时一条 grep "\[20260805/multi_factor\]" logs/daily.log | grep -E "ERROR|CRITICAL" 就能精准还原那次运行的全貌。这就是"出问题时能查得到"的最后一环。


八、装回骨架 & 把 S2-021 runner 改造成"专业日志版"

落地位置(直接放进 S2-023 骨架):

text 复制代码
ashare-quant/
├── config/                  # settings.yaml / secrets.yaml / strategies/*.yaml(S2-023 已建)
└── src/utils/
    ├── __init__.py
    ├── config.py            # ← 本篇新增
    └── logger.py            # ← 本篇新增(替换 S2-021 的 open().write())

scripts/ 下的入口脚本(如 S2-021 的 daily_runner.py)开头这样接:

python 复制代码
from src.utils.config import ProjectConfig
from src.utils.logger import get_logger, bind_context

cfg = ProjectConfig.load(ROOT)                       # 强类型配置
log = bind_context(get_logger("daily", log_dir=cfg.settings.paths.log_dir, level=logging.INFO),
                   run_id=stamp, strategy="multi_factor")
log.info("加载配置完成,风控上限 单票=%.0f%%", cfg.settings.risk.max_single_position * 100)

S2-021 runner 的"改造前/后"对照 (闭环呼应),把原来那段 open(log_path).write(...) 换成:

python 复制代码
# 改造前(S2-021):自写文件、无级别、无上下文
with open(log_path, "a", encoding="utf-8") as log:
    log.write(f"  [OK] {name}: ...\n")

# 改造后(S2-024):分级 + 上下文 + 滚动落盘,一行顶过去十行
log.info("计算完成 %s: 累计%.1f%% 夏普%.2f 回撤%.1f%%",
         name, total_return_pct, sharpe, max_drawdown_pct)

六大排雷:

⚠️ 坑 1:把 token 打进日志。mask_secret(),永远只记"已配置"不记原文(见第五节)。
⚠️ 坑 2:日志单文件无限增长。 必须用 RotatingFileHandler 按大小切割,别用裸 open 追加------跑三年就是一个几百 MB 的怪物文件。
⚠️ 坑 3:重复挂 handler 导致日志翻倍。 任何 get_logger 都要先 if logger.handlers: return,或用本篇的幂等工厂。
⚠️ 坑 4:配置改了代码没改 import。 ProjectConfig.load 返回强类型对象,访问字段用 . 而非 [""]------IDE 能补全、mypy 能查,拼错字段名立刻红。
⚠️ 坑 5:把 secrets.yaml 提交进 Git。 它早被 S2-022 的 .gitignore 排除;若你手滑 git add -f,密钥就泄露了。提交前 git status 看一眼。
⚠️ 坑 6:超时/限流不设级别。 限流退避是 WARNING(可恢复),某标的彻底失败是 ERROR(继续跑别的),风控缺失是 CRITICAL(阻断)。级别分错,半夜告警你会分不清轻重。


九、本篇小结 & 下篇预告

小结:

  • 为什么是最后两块砖 :配置是仪表盘、日志是黑匣子;S2-023 骨架里 settings.yaml 还是样例、logger.py 还空着,本篇填上;
  • 配置管理 :用 pydantic 把 YAML 变成强类型对象,ProjectConfig.load(root) 一次加载全局 + 密钥 + 策略;
  • 三条防线extra="forbid" 防 typo、gt/le/ge 边界校验拦离谱参数、version 必填做版本归因;
  • 密钥安全mask_secret() 打码 + secrets.yaml.gitignore 排除,双保险延续 S2-022;实测坏配置(范围越界、typo 键)均被 pydantic 启动期拦截;
  • 日志系统get_logger 幂等工厂 + 控制台/滚动文件双出口(RotatingFileHandlerlogs/UTF-8delay=True)+ 五级别;
  • 上下文绑定bind_context 让每条日志带 run_id/strategy,出问题时按"运行/策略"精准检索;
  • 闭环落地 :两模块装回 S2-023 骨架的 src/utils/;给出 S2-021 runner 的"改造前/后"对照;六大排雷(token 进日志、单文件暴涨、handler 翻倍、import 拼错、密钥提交、级别错配)。

下一篇预告 :专栏 S2 第十三篇《用 type hints + pydantic 给量化代码上保险》(S2-025)。本篇只把 pydantic 当"配置校验器"用------给订单、持仓、信号这些领域对象建强类型 model,把 type hints 铺满整个 src/,让 mypy 在跑之前就替你抓出"把字符串当价格传进去"这类低级 bug,才是 pydantic 的真正全身用法。我们下篇见。


附:本篇可运行脚本

bash 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
S2-024 配套演示:配置管理 + 日志系统 跑通全流程。

这是《配置管理与日志系统:出问题时能查得到》一文的配套脚本。它做三件事:
  1. 在临时目录生成一套"样例工程"(config/settings.yaml + secrets.yaml +
     strategies/*.yaml + logs/),相当于把 S2-023 骨架里的 config 填上真值;
  2. 用 src/utils/config.py 加载并校验配置(展示强类型对象 + 密钥打码);
     再用一个"坏配置"演示 pydantic 如何在启动期就拦住离谱参数;
  3. 用 src/utils/logger.py 写 5 级日志 + 带 run_id/strategy 上下文,并回读日志文件。

运行方式(在仓库根目录 `量化博客全案/` 下,任意装了 pydantic+pyyaml 的 Python):
    python src/s2_024_demo.py

仅用量化技术教学,不构成任何投资建议或个股推荐。
"""

from __future__ import annotations

import os
import sys
import tempfile
from pathlib import Path

# 让 `from src.utils.xxx import` 成立:把仓库根(src 的上级)加进 path
SRC_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.dirname(SRC_DIR)
if ROOT_DIR not in sys.path:
    sys.path.insert(0, ROOT_DIR)

from src.utils.config import ProjectConfig, Settings, Secrets, mask_secret  # noqa: E402
from src.utils.logger import get_logger, bind_context, mask_secret as log_mask  # noqa: E402

# 样例全局配置(字段与 S2-023 脚手架生成的一致)
GOOD_SETTINGS = """paths:
  data_dir: data
  daily_dir: data/daily
  factor_dir: data/factor
  log_dir: logs
  result_dir: results
data_source:
  primary: akshare
  backup: tushare
  rate_limit_sec: 0.3
schedule:
  update_data_at: "16:00"
  generate_orders_at: "08:45"
  skip_non_trading_day: true
risk:
  max_single_position: 0.25
  max_single_industry: 0.30
  max_order_lines_per_day: 20
"""

GOOD_MULTI_FACTOR = """version: 1.0
name: multi_factor
universe: 000300.SH
rebalance: monthly
factors: [value, momentum, quality]
weights:
  method: ic_weighted
  top_n: 30
execution_time: close
"""

GOOD_CB = """version: 1.0
name: cb_double_low
universe: convertible_bond
rebalance: weekly
double_low:
  top_n: 20
  max_price: 115
  min_liquidity: 50000000
execution_time: close
"""

# 真实密钥样例(仅存在于本演示的临时目录,不会进你的 git 仓库)
GOOD_SECRETS = """tushare:
  token: "tushare_9f3a2b7c8e1d4f60a5b2c3d4e5f60718"
dingtalk:
  webhook: "https://oapi.dingtalk.com/robot/send?access_token=abcd1234efgh5678"
email:
  smtp_user: "demo@qq.com"
  smtp_auth_code: "abcdwxyzefgh5678"
"""


def build_sample_project(base: Path) -> Path:
    """在 base 下生成一套最小样例工程,返回工程根目录。"""
    root = base / "s2_024_demo_project"
    cfg = root / "config" / "strategies"
    logs = root / "logs"
    cfg.mkdir(parents=True, exist_ok=True)
    logs.mkdir(parents=True, exist_ok=True)
    (root / "config" / "settings.yaml").write_text(GOOD_SETTINGS, encoding="utf-8")
    (root / "config" / "secrets.yaml").write_text(GOOD_SECRETS, encoding="utf-8")
    (cfg / "multi_factor.yaml").write_text(GOOD_MULTI_FACTOR, encoding="utf-8")
    (cfg / "cb_double_low.yaml").write_text(GOOD_CB, encoding="utf-8")
    return root


def demo_config_ok(root: Path) -> None:
    print("\n=== ① 配置加载 + 校验(正常路径)===")
    cfg = ProjectConfig.load(root)
    s = cfg.settings
    print(f"  数据源主用: {s.data_source.primary} | 限流间隔: {s.data_source.rate_limit_sec}s")
    print(f"  风控: 单票<={s.risk.max_single_position} 单行业<={s.risk.max_single_industry} "
          f"单日清单<={s.risk.max_order_lines_per_day}笔")
    print(f"  可用策略: {cfg.list_strategies()}")
    mf = cfg.get_strategy("multi_factor")
    print(f"  策略 multi_factor: version={mf.version} top_n={mf.weights['top_n']}")
    print(f"  密钥摘要(已打码): {cfg.mask_secrets()}")


def _show_first_error(label: str, raw_yaml: str, root: Path) -> None:
    """把一份坏 YAML 落盘并校验,打印 pydantic 拦下来的第一条错误。"""
    bad_path = root / "config" / f"settings_bad_{label}.yaml"
    bad_path.write_text(raw_yaml, encoding="utf-8")
    try:
        import yaml

        Settings.model_validate(yaml.safe_load(bad_path.read_text(encoding="utf-8")))
        print(f"    [X] {label}: 居然没报错?检查代码。")
    except Exception as e:  # noqa: BLE001
        # 只打印第一条错误,避免刷屏
        first = str(e).splitlines()[0]
        print(f"    [√] {label}: pydantic 拦住 → {first}")


def demo_config_bad(root: Path) -> None:
    print("\n=== ② 坏配置触发 pydantic 报错(启动期就拦住)===")
    # 用例 A:范围越界------单票仓位写成 150%,明显反直觉
    over_range = GOOD_SETTINGS.replace("max_single_position: 0.25", "max_single_position: 1.5")
    _show_first_error("范围越界(单票=1.5)", over_range, root)
    # 用例 B:typo 键------风控里多打一个字母,extra=forbid 直接拒
    typo = GOOD_SETTINGS + "risk:\n  oops_typo_key: 0.99\n"
    _show_first_error("多余键(typo)", typo, root)


def demo_logging(root: Path) -> None:
    print("\n=== ③ 日志系统:5 级 + 上下文绑定 → logs/ ===")
    log = bind_context(
        get_logger("s2_024_demo", log_dir=root / "logs", level=10),  # 10=DEBUG
        run_id="20260805",
        strategy="multi_factor",
    )
    log.debug("这是 DEBUG:只有排查时才想看")
    log.info("每日任务启动,加载配置完成")
    log.warning("tushare 限流,已自动退避 0.3s")
    log.error("下载 600000.SH 失败:连接超时(将重试)")
    log.critical("风控模块未加载,已阻断下单!")

    # 密钥绝不进日志:用打码后的值记录"已配置",而非原文
    token = "tushare_9f3a2b7c8e1d4f60a5b2c3d4e5f60718"
    log.info("已加载 tushare token: %s", mask_secret(token))

    log_path = root / "logs" / "s2_024_demo.log"
    print(f"  日志已落盘: {log_path}")
    print("  ── 回读日志文件末尾 4 行 ──")
    lines = log_path.read_text(encoding="utf-8").strip().splitlines()[-4:]
    for ln in lines:
        print(f"    {ln}")


def main() -> int:
    base = Path(tempfile.gettempdir())
    root = build_sample_project(base)
    print(f"[S2-024] 样例工程位于: {root}")
    demo_config_ok(root)
    demo_config_bad(root)
    demo_logging(root)
    print("\n[S2-024] 演示完毕。生产用法:把这俩模块拷进你 S2-023 骨架的 src/utils/ 即可。")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
bash 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
S2-024 生产模块之一:配置管理(config.py)

作用:把散落的魔法数字、硬编码路径、裸 YAML 读入,换成「类型安全 + 范围校验 +
版本归因」的配置对象。直接放进 S2-023 工程骨架的 `src/utils/` 即可用。

设计要点(对应正文):
  1. pydantic 做"配置校验器":读 YAML → 校验类型/范围 → 拿到强类型对象;
     读错一个类型、超一个范围、多打一个字母,立刻报错,而不是运行时悄悄错。
  2. `extra="forbid"`:settings.yaml 里多敲一个键直接 ValidationError,专治 typo。
  3. 风控默认值带边界:`gt=0, le=1` 等,把"单票仓位 150%"这种离谱配置拦在启动期。
  4. 策略参数 `version` 必填:落实《04》§4.3"版本归因",三个月后还能定位当时用的哪版参数。
  5. secrets 单独加载且 `extra="allow"`:密钥随供应商增减,不进库(S2-022 已拦截)。
  6. 密钥绝不进日志:提供 `mask_secret()`,token/webhook 落盘前先打码。

依赖:pydantic>=2, pyyaml(已在 S2-023 的 pyproject/requirements 里)。

仅用于量化技术教学,不构成任何投资建议或个股推荐。
"""

from __future__ import annotations

import warnings
from pathlib import Path
from typing import Any

import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator

# ────────────────────────────────────────────────────────────────
# 基础约定:所有配置模型默认"禁止多余字段",配置里多打一个字母直接报错
# ────────────────────────────────────────────────────────────────
class _Strict(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=False)


def _valid_hhmm(cls, v: str) -> str:  # noqa: ANN001, N805
    """校验 "HH:MM" 格式(24 小时制),调度时间写错一笔就拦住。"""
    if not isinstance(v, str) or len(v) != 5 or v[2] != ":":
        raise ValueError(f"时间必须是 HH:MM 格式,收到: {v!r}")
    hh, mm = v.split(":")
    if not (hh.isdigit() and mm.isdigit()):
        raise ValueError(f"时间必须是数字 HH:MM,收到: {v!r}")
    h, m = int(hh), int(mm)
    if not (0 <= h <= 23 and 0 <= m <= 59):
        raise ValueError(f"时间超出范围 00:00~23:59,收到: {v!r}")
    return v


# ────────────────────────────────────────────────────────────────
# 分层配置模型(对应 S2-023 settings.yaml 的四个段落)
# ────────────────────────────────────────────────────────────────
class PathsConfig(_Strict):
    data_dir: str = "data"
    daily_dir: str = "data/daily"
    factor_dir: str = "data/factor"
    log_dir: str = "logs"
    result_dir: str = "results"


class DataSourceConfig(_Strict):
    primary: str = "akshare"
    backup: str = "tushare"
    rate_limit_sec: float = Field(0.3, ge=0, description="单请求最小间隔,防限流")


class ScheduleConfig(_Strict):
    update_data_at: str = "16:00"
    generate_orders_at: str = "08:45"
    skip_non_trading_day: bool = True

    _check = field_validator("update_data_at", "generate_orders_at")(_valid_hhmm)


class RiskConfig(_Strict):
    max_single_position: float = Field(0.25, gt=0, le=1, description="单票仓位上限(0~1)")
    max_single_industry: float = Field(0.30, gt=0, le=1, description="单行业仓位上限(0~1)")
    max_order_lines_per_day: int = Field(20, ge=1, description="单日清单笔数上限")


class Settings(_Strict):
    """全局配置(对应 config/settings.yaml)。"""

    paths: PathsConfig = Field(default_factory=PathsConfig)
    data_source: DataSourceConfig = Field(default_factory=DataSourceConfig)
    schedule: ScheduleConfig = Field(default_factory=ScheduleConfig)
    risk: RiskConfig = Field(default_factory=RiskConfig)


class Secrets(BaseModel):
    """敏感配置(对应 config/secrets.yaml,被 .gitignore 排除)。

    用 `extra="allow"` 而非 forbid:供应商字段(tushare/dingtalk/email...)
    会随项目增长,未知键先放行,由使用者自己约束。
    """

    model_config = ConfigDict(extra="allow")
    tushare: dict[str, Any] = Field(default_factory=dict)
    dingtalk: dict[str, Any] = Field(default_factory=dict)
    email: dict[str, Any] = Field(default_factory=dict)


class StrategyConfig(BaseModel):
    """单策略参数(对应 config/strategies/<name>.yaml)。

    `extra="allow"`:策略字段千奇百怪,放行;但 `name`/`version` 必填------
    version 是用来做"版本归因"的硬约束(见《04》§4.3)。
    """

    model_config = ConfigDict(extra="allow")
    name: str
    version: float = Field(..., gt=0, description="参数版本号,每次改参 +0.1,配合 git tag 做归因")


# ────────────────────────────────────────────────────────────────
# 编排层:把零散 YAML 组装成一个强类型、可直接用的 ProjectConfig
# ────────────────────────────────────────────────────────────────
class ProjectConfig:
    """一次加载、处处可用的项目配置门面。

    用法:
        cfg = ProjectConfig.load("D:/ashare-quant")
        cfg.settings.risk.max_single_position     # 0.25(强类型 float)
        cfg.get_strategy("multi_factor").version  # 1.0(必填)
        cfg.mask_secrets()                         # 把密钥打码后给日志用
    """

    def __init__(
        self,
        root: Path,
        settings: Settings,
        secrets: Secrets,
        strategy_dir: Path,
    ) -> None:
        self.root = Path(root)
        self.settings = settings
        self.secrets = secrets
        self.strategy_dir = Path(strategy_dir)

    # ---- 类方法:从磁盘加载 ----
    @classmethod
    def load(cls, root: str | Path) -> "ProjectConfig":
        root = Path(root)
        settings_path = root / "config" / "settings.yaml"
        if not settings_path.exists():
            raise FileNotFoundError(f"找不到全局配置: {settings_path}")

        settings = Settings.model_validate(
            yaml.safe_load(settings_path.read_text(encoding="utf-8")) or {}
        )

        secrets_path = root / "config" / "secrets.yaml"
        if secrets_path.exists():
            secrets = Secrets.model_validate(
                yaml.safe_load(secrets_path.read_text(encoding="utf-8")) or {}
            )
        else:
            secrets = Secrets()
            warnings.warn(
                f"未找到 {secrets_path}(真实密钥被 .gitignore 排除是正常的)。\n"
                "复制 config/secrets.yaml.example 为 secrets.yaml 并填入真值后即可使用相关功能。",
                stacklevel=2,
            )

        return cls(root, settings, secrets, root / "config" / "strategies")

    # ---- 实例方法:取策略配置 ----
    def get_strategy(self, name: str) -> StrategyConfig:
        path = self.strategy_dir / f"{name}.yaml"
        if not path.exists():
            raise FileNotFoundError(f"找不到策略配置: {path}")
        return StrategyConfig.model_validate(
            yaml.safe_load(path.read_text(encoding="utf-8")) or {}
        )

    def list_strategies(self) -> list[str]:
        if not self.strategy_dir.is_dir():
            return []
        return sorted(p.stem for p in self.strategy_dir.glob("*.yaml"))

    # ---- 密钥打码:永远不要让 token/webhook 进日志 ----
    def mask_secrets(self) -> dict[str, Any]:
        """返回一份"安全可打印"的密钥摘要(值全部打码,只留键名)。

        示例输出:
            {"tushare": {"token": "****"}, "dingtalk": {"webhook": "****"}}
        """
        out: dict[str, Any] = {}
        for provider, blob in self.secrets.__dict__.items():
            if provider.startswith("_"):
                continue
            if isinstance(blob, dict) and blob:
                out[provider] = {k: "****" for k in blob}
            elif blob:
                out[provider] = "****"
        return out


def mask_secret(value: str, head: int = 4, tail: int = 2) -> str:
    """把单个密钥值打码:`sk_live_abc...xyz` → `sk_l...yz`。

    Args:
        value: 原始密钥字符串。
        head: 保留前几位明文。
        tail: 保留后几位明文。

    Returns:
        打码后的字符串;过短则全打星。
    """
    s = str(value)
    if len(s) <= head + tail + 1:
        return "****"
    return f"{s[:head]}...{s[-tail:]}"
bash 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
S2-024 生产模块之二:日志系统(logger.py)

作用:把 `print()` 和 S2-021 runner 里那种"自己 open 文件 write"的散装日志,
换成「分级 + 滚动落盘 logs/ + 上下文绑定」的专业 LoggerFactory。直接放进
S2-023 工程骨架的 `src/utils/` 即可用。

设计要点(对应正文):
  1. 五个级别 DEBUG/INFO/WARNING/ERROR/CRITICAL 各司其职,别再用 print 一把梭。
  2. 双出口:控制台(人看)+ 滚动文件(RotatingFileHandler 落 logs/,UTF-8、
     delay=True 首次写才建文件、按大小切割备份)。定时任务没有可见窗口,
     日志文件是你事后排查的唯一证据(接 S2-021 的"可观测三件套")。
  3. 幂等:同一个 name 反复 get_logger 不会重复挂 handler(新手最高频 bug)。
  4. 上下文绑定:用 LoggerAdapter + Filter,让每条日志都带上 run_id / strategy,
     出问题时能按"哪次运行、哪个策略"精准检索。
  5. 密钥绝不进日志:配合 config.py 的 mask_secret,token/webhook 落盘前打码。

依赖:仅 Python 标准库(logging / logging.handlers)。

仅用于量化技术教学,不构成任何投资建议或个股推荐。
"""

from __future__ import annotations

import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path

# 统一格式:时间 | 级别 | 模块 | [run_id/strategy] 消息
_FMT = "%(asctime)s | %(levelname)-7s | %(name)s | [%(run_id)s/%(strategy)s] %(message)s"
_DATE_FMT = "%Y-%m-%d %H:%M:%S"


class _ContextFilter(logging.Filter):
    """给日志记录补默认值,避免上下文字段缺失时 format 抛 KeyError。"""

    def filter(self, record: logging.LogRecord) -> bool:
        if not hasattr(record, "run_id"):
            record.run_id = "-"
        if not hasattr(record, "strategy"):
            record.strategy = "-"
        return True


class _ContextAdapter(logging.LoggerAdapter):
    """把 run_id / strategy 等上下文注入每一条日志,无需每次手写 extra。"""

    def process(self, msg: str, kwargs: dict) -> tuple[str, dict]:
        kwargs.setdefault("extra", {})
        merged = {**self.extra, **kwargs["extra"]}
        kwargs["extra"] = merged
        return msg, kwargs


def get_logger(
    name: str = "quant",
    log_dir: str | Path = "logs",
    level: int = logging.INFO,
    rotate_mb: int = 5,
    backups: int = 5,
    console: bool = True,
) -> logging.Logger:
    """获取(或重建)一个已配置好的 logger。

    幂等:同一 `name` 第二次调用直接返回已配置实例,不会重复挂 handler。

    Args:
        name: logger 名称,也用作日志文件名前缀(logs/<name>.log)。
        log_dir: 日志目录,默认项目根下的 logs/(S2-023 已在 .gitignore 排除)。
        level: 最低记录级别,默认 INFO。DEBUG 适合排查,生产可设 INFO/ WARNING。
        rotate_mb: 单文件多大(MB)后切割,默认 5MB。
        backups: 保留几个历史备份,默认 5 个。
        console: 是否同时输出到控制台,默认 True(定时任务可设 False)。

    Returns:
        配置完成的 `logging.Logger`。
    """
    logger = logging.getLogger(name)
    if logger.handlers:  # 已配置,直接返回(幂等)
        return logger

    logger.setLevel(level)
    logger.propagate = False  # 不向 root 冒泡,避免重复输出

    ctx = _ContextFilter()
    fmt = logging.Formatter(_FMT, _DATE_FMT)

    if console:
        ch = logging.StreamHandler()
        ch.setFormatter(fmt)
        ch.addFilter(ctx)
        logger.addHandler(ch)

    log_dir = Path(log_dir)
    log_dir.mkdir(parents=True, exist_ok=True)
    # delay=True:首次真正写日志才创建文件,空跑不污染 logs/
    fh = RotatingFileHandler(
        log_dir / f"{name}.log",
        maxBytes=rotate_mb * 1024 * 1024,
        backupCount=backups,
        encoding="utf-8",
        delay=True,
    )
    fh.setFormatter(fmt)
    fh.addFilter(ctx)
    logger.addHandler(fh)
    return logger


def bind_context(logger: logging.Logger, **ctx: str) -> logging.LoggerAdapter:
    """给 logger 绑定运行上下文(如 run_id、strategy),返回带上下文的适配器。

    用法:
        log = bind_context(get_logger("daily"), run_id="20260805", strategy="multi_factor")
        log.info("开始计算净值")   # 自动带 [20260805/multi_factor]
    """
    return _ContextAdapter(logger, ctx)


def mask_secret(value: str, head: int = 4, tail: int = 2) -> str:
    """把密钥值打码:`sk_live_abc...xyz` → `sk_l...yz`。

    与 config.py 的同名函数保持一致;日志模块单独放一份便于独立 import。
    """
    s = str(value)
    if len(s) <= head + tail + 1:
        return "****"
    return f"{s[:head]}...{s[-tail:]}"

1. 配置 + 日志 生产模块(直接拷进你 S2-023 骨架的 src/utils/):

  • src/utils/config.py:pydantic 配置校验(四层模型 _Strict/PathsConfig/DataSourceConfig/ScheduleConfig/RiskConfig/Settings/Secrets/StrategyConfig + 编排层 ProjectConfig + mask_secret)。依赖 pydantic>=2pyyaml
  • src/utils/logger.py:分级日志工厂 get_logger + 上下文绑定 bind_context + mask_secret。仅依赖标准库 logging

2. 一键演示(已实测可跑): src/s2_024_demo.py

在仓库根目录 量化博客全案/ 下,任意装了 pydantic + pyyaml 的 Python 3.8+ 都能跑:

bash 复制代码
# 推荐托管 Python 3.13(已装 pydantic 2.13.4 / pyyaml 6.0.3)
python src/s2_024_demo.py

本机实测(托管 Python 3.13)做了三件事并全部通过:

  • 在临时目录生成样例工程(含真实 secrets.yaml 演示打码),ProjectConfig.load 成功,输出强类型配置 + 策略列表 + 打码密钥摘要;
  • 两个坏配置用例(单票=1.5 范围越界、oops_typo_key 多余键)均被 pydantic 启动期拦截;
  • 五个级别日志 + run_id/strategy 上下文写入 logs/s2_024_demo.log,并回读验证落盘成功、token 已打码为 tush...18

3. 验证"配置可管、出事可查"的最小命令流(在你的 ashare-quant 项目里):

bash 复制代码
pip install pydantic pyyaml        # 在 S2-013 的 conda 环境 ashare 里
python -c "from src.utils.config import ProjectConfig; c=ProjectConfig.load('.'); print(c.settings.risk)"
python -c "from src.utils.logger import get_logger, bind_context; \
           log=bind_context(get_logger('test', log_dir='logs'), run_id='demo', strategy='x'); \
           log.warning('配置与日志上线,出事查得到')"
ls logs/                            # 应看到 test.log(已落盘、UTF-8、带上下文)

所有数据均为合成数据,仅用于技术教学,不构成任何投资建议。


免责声明:本文所有内容仅用于量化投资技术教学与知识分享,文中的代码示例、配置结构与工具配置均不构成任何投资建议或个股推荐。投资有风险,入市需谨慎;实际交易前请务必用自己的真实数据充分回测,并充分了解相关风险。

相关推荐
李可以量化3 天前
Redis 从了解到精通(三)下:性能基准测试与量化场景性能避坑指南
redis·git·python·量化交易·qmt·ptrade
Thom58013 天前
【迅投 QMT】QMT如何获取ETF申赎清单?download_etf_info()与get_etf_info()教程
人工智能·经验分享·量化交易·ptrade·量化编程
茨球是只猫18 天前
A 股 AI 量化全链路系统技术拆解:分层架构、双引擎验证与低换手实盘闭环
人工智能·机器学习·架构·量化交易
Thom58018 天前
【聚宽 JoinQuant】聚宽如何获取持仓和订单?get_orders()与get_open_orders()教程
人工智能·经验分享·量化交易·聚宽·量化编程
ATMQuant19 天前
以AI量化为生:25.vnpy 4.4升级实战 - 魔改版框架如何安全跟进上游
人工智能·python·量化交易·vnpy
李可以量化19 天前
量化高性能服务框架 Tornado 全面解析(上):异步非阻塞的核心能力与场景落地
大数据·python·量化交易·tornado·qmt·ptrade