上一篇我们解决了机器学习评估的严谨性问题。本篇从模型回到地面:前四十篇的代码几乎都是「单脚本」——一个 .py 或一个 notebook 从头跑到尾。数据量一大、策略一多,单脚本的维护成本会指数级上升。这一篇我们把量化项目「工程化」:合理的目录结构、配置与代码分离、以及一个专门为分析而生的本地数据库 DuckDB。
本篇目标
- 能把单脚本项目重构成「数据 / 配置 / 策略 / 回测 / 日志」分层的模块化结构
- 能用 YAML 配置文件管理策略参数,实现「改参数不改代码」
- 能用 DuckDB 直接查询 CSV/Parquet 文件,并与 pandas 互转
- 能说清 DuckDB 相比 SQLite 在分析场景下的优势(列式存储、向量化执行)
- 能跑通一个最小但完整的重构后项目
核心概念
为什么单脚本撑不久
回忆第 18 篇到第 25 篇的回测代码:数据获取、指标计算、信号生成、回测循环、画图输出,全部挤在一个文件里。三个问题迟早爆发:参数改不动(双均线的周期散落在代码各处,改一次要全文搜索);复用不了(第 25 篇想做参数网格,只能复制粘贴回测函数);结果不可追溯(这次回测用的是哪段数据、哪组参数,一周后没人说得清)。工程化的目标就是解决这三件事:参数集中、逻辑分层、结果留痕。
推荐目录结构
quant-project/
├── config/
│ └── strategy.yaml # 策略参数:周期、费率、区间
├── data/
│ └── raw/ parquet 文件 # 本地行情库
├── strategies/
│ └── ma_cross.py # 策略:只管信号
├── backtest/
│ └── engine.py # 引擎:只管撮合与记账
├── scripts/
│ └── update_data.py # 数据更新入口
├── logs/ # 运行日志(第 43 篇展开)
└── main.py # 总入口
分层原则一句话:数据脚本管进、策略管想法、引擎管执行、入口管串联。策略代码里不应出现 ak. 开头的东西,引擎代码里不应出现任何均线周期参数——它们只从配置读。
配置与代码分离
参数写进 config/strategy.yaml,代码用 yaml.safe_load 读入。好处立竿见影:改参数就是改文本文件,git 里每次参数变更都有 diff 可查(第 44 篇),回测结果与参数版本可以一一对应。YAML 里就是「键: 值」的缩进文本,新手十分钟上手。
DuckDB:分析型本地数据库
第 8 篇我们用过 SQLite 存行情。DuckDB 是它的「分析场景版」:
| SQLite | DuckDB | |
|---|---|---|
| 存储方式 | 行式 | 列式 |
| 擅长 | 单条读写(交易型) | 聚合扫描(分析型) |
| 查询大表 | 慢 | 快(向量化执行) |
| 直查 CSV/Parquet | 不支持 | 原生支持 |
| 部署 | 单文件 | 单文件,uv pip 即装 |
金融分析几乎全是「按列聚合」:算某列的均值、按月分组求和、按代码筛选——列式存储让这类查询只需扫描涉及的列,GB 级行情数据也能亚秒级出结果。最妙的是 DuckDB 不需要先导入:duckdb.sql("SELECT * FROM 'data/raw/xxx.parquet'") 直接把文件当表查。
划重点:DuckDB 与 pandas 的关系是「互相成就」而不是替代——
duckdb.sql(...).df()一行把查询结果变成 DataFrame,DataFrame 也能直接REGISTER给 SQL 用。重计算交给 SQL、可视化交给 pandas,是本地量化研究的舒适分工。
完整代码
本篇依赖:uv pip install duckdb pyyaml pandas numpy(pyarrow 装 Parquet 支持时已有)。
# ---------- 0. 准备:造一份行情 Parquet(真实项目由 scripts/update_data.py 生成) ----------
import numpy as np
import pandas as pd
from pathlib import Path
Path("data/raw").mkdir(parents=True, exist_ok=True)
rng = np.random.default_rng(41)
n = 1000
df = pd.DataFrame({
"code": "600519",
"date": pd.bdate_range("2021-01-01", periods=n),
"close": 100 * np.exp(np.cumsum(rng.normal(0.0004, 0.015, n))),
})
df["open"] = df["close"] * (1 + rng.normal(0, 0.003, n).cumsum() * 0)
df.to_parquet("data/raw/600519.parquet", index=False)
print(f"已生成行情文件: {len(df)} 行")
# ---------- config/strategy.yaml 的内容 ----------
# 双均线策略参数(示意)
symbol: "600519"
fast: 5
slow: 20
fee: 0.001
start: "2021-01-01"
end: "2024-12-31"
# ---------- 1. strategies/ma_cross.py 的内容 ----------
import pandas as pd
def generate_signals(df, fast, slow):
"""输入行情 DataFrame,输出带 signal 列的 DataFrame。只管信号。"""
out = df.copy()
out["ma_fast"] = out["close"].rolling(fast).mean()
out["ma_slow"] = out["close"].rolling(slow).mean()
out["signal"] = (out["ma_fast"] > out["ma_slow"]).astype(int)
return out
# ---------- 2. backtest/engine.py 的内容 ----------
import pandas as pd
def run_vectorized(df, fee):
"""输入带 signal 列的 DataFrame,输出策略日收益。只管执行。"""
pos = df["signal"].shift(1).fillna(0) # 信号次日生效,无未来函数
ret = df["close"].pct_change().fillna(0)
turnover = pos.diff().abs().fillna(0)
return pos * ret - turnover * fee
# ---------- 3. main.py:读配置 → DuckDB 取数 → 跑策略 ----------
import duckdb
import yaml
import pandas as pd
from pathlib import Path
from strategies.ma_cross import generate_signals
from backtest.engine import run_vectorized
def load_config(path="config/strategy.yaml"):
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def query_data(cfg):
"""DuckDB 直接查 Parquet,WHERE 在 SQL 里做,少拉无效数据"""
sql = f"""
SELECT date, open, close
FROM 'data/raw/{cfg['symbol']}.parquet'
WHERE date BETWEEN '{cfg['start']}' AND '{cfg['end']}'
ORDER BY date
"""
df = duckdb.sql(sql).df() # 查询结果 → DataFrame
df["date"] = pd.to_datetime(df["date"])
return df.set_index("date")
def main():
cfg = load_config()
df = query_data(cfg)
sig = generate_signals(df, cfg["fast"], cfg["slow"])
strat = run_vectorized(sig, cfg["fee"])
nav = (1 + strat).cumprod()
print(f"参数: fast={cfg['fast']}, slow={cfg['slow']}, "
f"fee={cfg['fee']}")
print(f"回测区间: {df.index[0].date()} ~ {df.index[-1].date()}, "
f"{len(df)} 根K线")
print(f"期末净值(示意): {nav.iloc[-1]:.3f}")
return cfg, nav
cfg, nav = main()
# ---------- 4. DuckDB 的分析威力:注册 DataFrame 直接 SQL ----------
import duckdb
# DataFrame 注册成临时表视图,SQL 与 pandas 无缝互通
duckdb.register("nav_view", nav.reset_index().rename(
columns={"index": "date", 0: "nav"}))
stats = duckdb.sql("""
SELECT
date_trunc('month', date) AS 月份,
last_value(nav) OVER (
PARTITION BY date_trunc('month', date)
ORDER BY date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS 月末净值
FROM nav_view
QUALIFY row_number() OVER (
PARTITION BY date_trunc('month', date) ORDER BY date DESC
) = 1
ORDER BY 月份
""").df()
print(stats.head(6).round(3).to_string(index=False))
代码逐段解释
- 造数据:模拟一段千行行情存成 Parquet——真实项目里这一步由第 42 篇的数据管道完成。Parquet 列式存储 + DuckDB 是本篇推荐的本地行情库组合。
- YAML 配置:symbol、均线周期、费率、区间全部集中。改参数 = 改这个文件,代码零改动;git diff 一眼看清「这次回测和上次差在哪」。
- 策略与引擎分层:
generate_signals不知道费率的存在,run_vectorized不知道均线的存在——单一职责让两者都能被第 25 篇那样的网格搜索复用。shift(1)依旧守着无未来函数的底线。 - 主入口:
load_config→query_data(DuckDB 直查 Parquet,过滤条件在 SQL 里做,文件再大也只拉需要的行)→ 策略 → 引擎 → 输出。这条链路就是重构后项目的完整骨架。 - SQL×pandas 互通:
register把净值 DataFrame 注册成视图,QUALIFY窗口函数一条 SQL 取出每月月末净值——这类「分组取末行」用 pandas 写要 groupby+tail,SQL 写法同样简洁,且数据量大时快得多。
示例输出(示意,模拟行情结果):
已生成行情文件: 1000 行
参数: fast=5, slow=20, fee=0.001
回测区间: 2021-01-01 ~ 2024-12-06, 1000 根K线
期末净值(示意): 1.412
月份 月末净值
2021-01-01 1.018
2021-02-01 0.993
2021-03-01 1.055
2021-04-01 1.021
2021-05-01 1.090
2021-06-01 1.112
常见坑与小练习
常见坑
- 重构半途而废:最常见的失败是「新结构和旧脚本并存」,改 bug 时不知道该改哪份。定个截止日:结构搭好后旧脚本只读不写,逐步迁移。
- 配置里塞进逻辑:YAML 管参数,别把「if 快线>慢线则买」这种逻辑写成配置项——逻辑属于 strategies/,配置只放数值与开关。
- DuckDB 数据库文件与源文件混用:既查 Parquet 又建 .duckb 库再导一份,两份数据迟早不同步。个人研究推荐「Parquet 为唯一数据源、DuckDB 无状态直查」的轻量模式。
- 忘了虚拟环境与依赖记录:换台机器跑不起来才发现依赖没记录。
uv pip freeze > requirements.txt应成为习惯;用 uv 的项目模式时,pyproject.toml与uv.lock本身就是完整的依赖档案(第 44 篇会配合 git 讲)。 - 目录层级过深:为了「规范」套五六层包结构,import 路径长到离谱。个人项目三层足够:scripts / strategies / backtest。
小练习
- 在
config/strategy.yaml里加一个stop_loss: 0.08字段,改造run_vectorized或新增函数实现读配置的固定止损,跑通「改配置不改引擎」的完整闭环。思路提示:止损属于「退出规则」,可参考第 35 篇把它做成独立函数,由 main.py 串联。
- 用 DuckDB 一条 SQL 统计这段行情的月度波动率(月内日收益标准差 × √21),与 pandas 的 groupby 版本对比代码量。
思路提示:
SELECT date_trunc('month', date), stddev_samp(...) OVER (...),或先算日收益列再按月分组聚合。 - 给
query_data加上多代码支持(config 里 symbol 改成列表),用UNION ALL或read_parquet('data/raw/*.parquet')一次查多只股票。思路提示:
read_parquet通配符会自动带上 code 列(前提是造数据时写入了),返回后用 groupby 分股票处理。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。