系列名:《从零搭建你的 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工厂 + 控制台/滚动文件双出口(RotatingFileHandler落logs/、UTF-8、幂等防重复 handler); - 上下文绑定 :用
LoggerAdapter让每条日志都带run_id/strategy,出问题时按"哪次运行、哪个策略"精准检索; - 怎么把这两个模块装回 S2-023 的骨架,并把 S2-021 的 runner 改造成"专业日志版";
- 五个常见踩坑与排雷(含"单文件日志长到几十 GB"这种磁盘杀手)。
📖 前置知识
- 已按 S2-023 生成工程骨架,知道
config/settings.yaml、src/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)
三条防线(这是全文精华):
extra="forbid"(防 typo) :settings.yaml里把max_single_position拼成max_single_positon,pydantic 直接Extra inputs are not permitted,而不是静默忽略。配置文件的"错别字"是最阴险的 bug------它不崩溃,只是悄悄用默认值跑。- 边界校验
gt/le/ge(防离谱) :max_single_position: 1.5这种"单票 150%"在le=1面前当场被拒。把业务的硬约束写进类型,比在代码里到处assert可靠一万倍。 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_key。extra="forbid"当场拦截,告诉你"这个键我不认识"。
对比一下"没有校验"的世界:这两种错误都会静默通过 ,你的策略照常运行,直到某天发现组合异常才回头地毯式排查------而那时你早已分不清是这次手滑还是别处逻辑错了。配置校验的价值,就是把"三个月后才发现的惨案"压缩成"启动时一行红字"。
五、密钥管理:永远不进日志
量化项目离不了密钥:Tushare token、钉钉 webhook、邮箱授权码。它们一旦进日志、进 Git,泄露的后果是实打实的------别人拿你的 token 刷接口额度都算轻的。
两道保险("密钥不进库"):
- 文件层 :真实
config/secrets.yaml在 S2-022 的.gitignore里就已被排除,只提交secrets.yaml.example样例; - 日志层 :
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 的痛点):
- 五个级别各司其职 :
DEBUG(排查)/INFO(正常流水)/WARNING(可恢复异常,如限流退避)/ERROR(某标的失败但继续)/CRITICAL(风控未加载、阻断下单)。再也不用print一把梭,想筛 ERROR 一条命令搞定。 - 滚动落盘
logs/:RotatingFileHandler单文件到 5MB 自动切备份、保留 5 个,delay=True首次写才建文件------三年跑下来也不会产生几十 GB 的单文件把磁盘写满。 UTF-8编码:中文日志不乱码(S2-021 已强调过,这里固化进工厂)。- 幂等 :同名 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幂等工厂 + 控制台/滚动文件双出口(RotatingFileHandler落logs/、UTF-8、delay=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>=2、pyyaml。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、带上下文)
所有数据均为合成数据,仅用于技术教学,不构成任何投资建议。
免责声明:本文所有内容仅用于量化投资技术教学与知识分享,文中的代码示例、配置结构与工具配置均不构成任何投资建议或个股推荐。投资有风险,入市需谨慎;实际交易前请务必用自己的真实数据充分回测,并充分了解相关风险。