Week 15:Mini 量化回测、动量规则与交易成本
Week 15 的目标是做一个最小可用回测:从价格数据出发,计算收益率,生成交易信号,转成持仓,加入交易成本,最后输出收益、波动、最大回撤、Sharpe、换手率等指标。
本文命令默认使用 fish shell。激活虚拟环境请使用:
source .venv/bin/activate.fish
0. 本周详细教学:语法、规范、验收
本节不是追加在尾部的复习,而是本周正文的入口。先读这里,再做后面的命令和项目。
0.1 本周真正要学会什么
| 维度 | 要求 |
|---|---|
| 知识点 | signal、position、shift(1)、成本、turnover |
| 代码语法 | 能从空文件写出本周核心脚本,而不是只复制运行 |
| 程序规范 | 函数拆分、路径清楚、输入输出明确、错误能解释 |
| 交付物 | projects/quant-backtest-mini/src/backtest.py |
| 验收方式 | 从 fish 终端运行命令,得到可复查的文件或指标 |
0.2 代码语法精讲
下面的代码不是最终答案,而是本周必须理解的最小骨架:
df["ret"] = df["close"].pct_change()
df["signal"] = (df["close"].pct_change(20) > 0).astype(float)
df["position"] = df["signal"].shift(1).fillna(0.0)
df["turnover"] = df["position"].diff().abs().fillna(0.0)
df["net_ret"] = df["position"] * df["ret"] - df["turnover"] * 0.0005
读代码时按四步检查:输入从哪里来;中间变量的类型和 shape 是什么;函数或脚本输出什么;哪些错误应该显式报出来。
0.3 本周程序规范
- 所有路径用相对路径或 `pathlib.Path`,不要写死 `/home/miku/...`。
- 核心逻辑进 `src/`,notebook 只做探索和解释。
- 每个脚本能从 fish 终端运行,并在 README 写出命令。
- 输出必须落盘到 `reports/`、`figures/` 或 `outputs/`,不能只在屏幕上看。
0.4 本周练习分层
| 层级 | 任务 | 不合格表现 | 合格验收 |
|---|---|---|---|
| 最小练习 | 手写上面的最小骨架 | 只在 notebook 里运行 | 终端运行成功 |
| 标准练习 | 把逻辑拆成函数/模块 | 一个大脚本从头写到尾 | 至少 2 个函数,职责清楚 |
| 项目练习 | 生成本周交付物 projects/quant-backtest-mini/src/backtest.py | 只有屏幕输出 | 文件落盘,可复查 |
| 复盘练习 | 写 3 个错误和修复 | 只写“已解决” | 写清报错、原因、修复、预防 |
0.5 本周和主线的连接
- 回到总计划:USTC AI / Quant 练习手册
- 查详细练习索引:技术练习详解
- 查质量评分:最终质量门槛
1. 本周学习目标
完成本周后,你应该能做到:
- 读取价格数据并计算日收益率。
- 实现一个简单动量策略:过去 N 日收益为正则持有,否则空仓。
- 正确处理信号和持仓的时间差,避免用当天收盘信号赚当天收益。
- 加入按换手计算的交易成本。
- 计算策略净值、最大回撤、年化收益、年化波动、Sharpe、turnover。
- 把代码拆成
backtest.py和metrics.py,形成一个可复用的小项目。
本周建议文件布局:
quant-backtest-mini/
├── data/
│ └── prices.csv
├── figures/
│ ├── equity_curve.png
│ └── drawdown.png
├── reports/
│ └── week15_backtest_notes.md
├── src/
│ └── quant_backtest_mini/
│ ├── __init__.py
│ ├── backtest.py
│ └── metrics.py
├── README.md
└── pyproject.toml
2. 前置条件
你需要理解:
- Week 13 的收益率、rolling 指标、画图。
- Week 14 的时间顺序和数据泄露风险。
pandas.Series.shift()的含义。- 年化指标只是约定换算,不代表未来收益保证。
创建项目:
mkdir -p ~/Code/ustc-stat-ai-quant
cd ~/Code/ustc-stat-ai-quant
mkdir quant-backtest-mini
cd quant-backtest-mini
uv init
uv venv
source .venv/bin/activate.fish
uv add pandas numpy matplotlib
mkdir -p data figures reports src/quant_backtest_mini
printf "" > src/quant_backtest_mini/__init__.py
code .
解释:
| 命令 | 作用 |
|---|---|
quant-backtest-mini |
本周是一个小项目,不再只叫 week15 |
src/quant_backtest_mini |
使用包结构,方便 Week 17 工程化 |
printf "" > __init__.py |
创建空包标记文件 |
uv add pandas numpy matplotlib |
本周只用基础数据处理和画图依赖 |
3. 准备价格数据
如果你有 Week 13 或 Week 14 的数据,可以复制:
cp ../week14-time-series-validation/data/prices.csv data/prices.csv
如果没有,创建 src/make_sample_prices.py:
from pathlib import Pathimport numpy as np import pandas as pd
rng = np.random.default_rng(15) dates = pd.bdate_range(“2020-01-02”, periods=756)
trend = np.where(np.arange(len(dates)) < 380, 0.0005, -0.0001) noise = rng.normal(0, 0.012, size=len(dates)) log_returns = trend + noise price = 100 * np.exp(np.cumsum(log_returns))
Path(“data”).mkdir(exist_ok=True) pd.DataFrame({“date”: dates, “close”: price}).to_csv(“data/prices.csv”, index=False) print(“saved data/prices.csv”)
运行:
python src/make_sample_prices.py
检查:
python -c "import pandas as pd; df = pd.read_csv('data/prices.csv'); print(df.head()); print(df.tail()); print(df.shape)"
真实项目中,数据可以来自指数、ETF、股票或课程提供的数据。本周重点不是数据接口,而是回测逻辑。
4. 回测里的时间顺序
假设策略规则是:
如果过去 N 日收益为正,则持有;否则空仓。
关键问题:什么时候知道信号,什么时候产生收益?
本教程采用保守设定:
t 日收盘后:计算过去 N 日收益,得到 t 日信号
t+1 日:根据信号持仓,获得 t+1 日收益
所以代码里必须做:
position = signal.shift(1)
strategy_return = position * asset_return
如果不 shift(1),就相当于用今天收盘后才知道的信号,赚了今天从开盘到收盘的收益,这是未来函数。
5. 实现风险指标 metrics.py
创建 src/quant_backtest_mini/metrics.py:
import numpy as np import pandas as pdTRADING_DAYS = 252
def annual_return(daily_returns: pd.Series) -> float: daily_returns = daily_returns.dropna() if daily_returns.empty: return 0.0 total_return = (1 + daily_returns).prod() - 1 years = len(daily_returns) / TRADING_DAYS if years <= 0: return 0.0 return float((1 + total_return) ** (1 / years) - 1)
def annual_volatility(daily_returns: pd.Series) -> float: daily_returns = daily_returns.dropna() return float(daily_returns.std(ddof=0) * np.sqrt(TRADING_DAYS))
def sharpe_ratio(daily_returns: pd.Series, risk_free_rate: float = 0.0) -> float: daily_returns = daily_returns.dropna() if daily_returns.empty: return 0.0 daily_rf = risk_free_rate / TRADING_DAYS excess = daily_returns - daily_rf vol = excess.std(ddof=0) if vol == 0 or np.isnan(vol): return 0.0 return float(excess.mean() / vol * np.sqrt(TRADING_DAYS))
def equity_curve(daily_returns: pd.Series) -> pd.Series: return (1 + daily_returns.fillna(0)).cumprod()
def drawdown(equity: pd.Series) -> pd.Series: running_max = equity.cummax() return equity / running_max - 1
def max_drawdown(daily_returns: pd.Series) -> float: equity = equity_curve(daily_returns) dd = drawdown(equity) return float(dd.min())
def summarize(daily_returns: pd.Series, turnover: pd.Series) -> dict[str, float]: return { “annual_return”: annual_return(daily_returns), “annual_volatility”: annual_volatility(daily_returns), “sharpe_ratio”: sharpe_ratio(daily_returns), “max_drawdown”: max_drawdown(daily_returns), “average_daily_turnover”: float(turnover.fillna(0).mean()), “total_turnover”: float(turnover.fillna(0).sum()), }
解释:
- 年化收益:把整个期间累计收益换算成年化。
- 年化波动:日收益标准差乘以交易日数量平方根。
- Sharpe:单位波动带来的超额收益。
- 最大回撤:从历史高点到之后低点的最大跌幅。
- turnover:持仓变化幅度,用来估算交易频率和成本。
6. 实现回测 backtest.py
创建 src/quant_backtest_mini/backtest.py:
from pathlib import Pathimport matplotlib.pyplot as plt import pandas as pd
from quant_backtest_mini.metrics import drawdown, equity_curve, summarize
FIGURE_DIR = Path(“figures”) FIGURE_DIR.mkdir(exist_ok=True)
def load_prices(path: str = “data/prices.csv”) -> pd.DataFrame: df = pd.read_csv(path, parse_dates=[“date”]) df = df.sort_values(“date”).set_index(“date”) return df
def make_momentum_signal(prices: pd.DataFrame, lookback: int) -> pd.Series: momentum_return = prices[“close”].pct_change(lookback) signal = (momentum_return > 0).astype(float) return signal
def run_backtest( prices: pd.DataFrame, lookback: int = 20, cost_bps: float = 5.0, ) -> pd.DataFrame: result = prices.copy() result[“asset_return”] = result[“close”].pct_change() result[“signal”] = make_momentum_signal(result, lookback=lookback)
plain # t 日收盘后产生的 signal,只能用于 t+1 日持仓 result[“position”] = result[“signal”].shift(1).fillna(0)
plain result[“gross_strategy_return”] = result[“position”] * result[“asset_return”] result[“turnover”] = result[“position”].diff().abs().fillna(result[“position”].abs())
plain cost_rate = cost_bps / 10_000 result[“transaction_cost”] = result[“turnover”] * cost_rate result[“net_strategy_return”] = result[“gross_strategy_return”] - result[“transaction_cost”] result[“benchmark_return”] = result[“asset_return”].fillna(0)
return result.dropna(subset=["asset_return"])def plot_results(result: pd.DataFrame) -> None: strategy_equity = equity_curve(result[“net_strategy_return”]) benchmark_equity = equity_curve(result[“benchmark_return”])
plain fig, ax = plt.subplots(figsize=(10, 5)) strategy_equity.plot(ax=ax, label=“momentum strategy net”) benchmark_equity.plot(ax=ax, label=“buy and hold”) ax.set_title(“Equity Curve”) ax.set_ylabel(“growth of 1”) ax.legend() fig.tight_layout() fig.savefig(FIGURE_DIR / “equity_curve.png”, dpi=150) plt.close(fig)
plain fig, ax = plt.subplots(figsize=(10, 4)) drawdown(strategy_equity).plot(ax=ax, label=“strategy drawdown”) drawdown(benchmark_equity).plot(ax=ax, label=“benchmark drawdown”) ax.set_title(“Drawdown”) ax.set_ylabel(“drawdown”) ax.legend() fig.tight_layout() fig.savefig(FIGURE_DIR / “drawdown.png”, dpi=150) plt.close(fig)
def main() -> None: prices = load_prices() result = run_backtest(prices, lookback=20, cost_bps=5.0)
plain strategy_summary = summarize(result[“net_strategy_return”], result[“turnover”]) benchmark_summary = summarize(result[“benchmark_return”], pd.Series(0, index=result.index))
plain print(“strategy”) for key, value in strategy_summary.items(): print(f”{key}: {value:.4f}”)
plain print(“benchmark”) for key, value in benchmark_summary.items(): print(f”{key}: {value:.4f}”)
plot_results(result) result.to_csv("reports/week15_backtest_daily_results.csv") print("saved figures and reports/week15_backtest_daily_results.csv")
if name == “main”: main()
运行:
python -m quant_backtest_mini.backtest
如果提示找不到包,确认你在项目根目录,并且包在 src/quant_backtest_mini/ 下。uv init 通常会配置好 src 布局;如果没有,可以先用下面命令运行:
PYTHONPATH=src python src/quant_backtest_mini/backtest.py
推荐最终使用模块方式运行,因为 Week 17 工程化会继续整理 CLI 入口。
7. 理解交易成本
本教程使用基点 bps:
1 bps = 0.01%
5 bps = 0.05%
交易成本计算:
cost_rate = cost_bps / 10_000
transaction_cost = turnover * cost_rate
net_return = gross_return - transaction_cost
如果持仓从 0 变成 1,turnover 为 1;如果从 1 变成 0,turnover 也为 1。频繁交易会产生更高成本。
必须比较:
gross_strategy_return:未扣成本
net_strategy_return:扣成本后
benchmark_return:买入并持有
如果策略只在未扣成本时有效,扣成本后失效,就不能说它有交易价值。
8. 风险指标怎么读
| 指标 | 含义 | 注意点 |
|---|---|---|
| annual return | 年化收益 | 样本短时容易夸大 |
| annual volatility | 年化波动 | 只看波动不看回撤不够 |
| Sharpe ratio | 单位风险收益 | 不稳定,受极端值影响 |
| max drawdown | 最大回撤 | 很贴近真实心理压力 |
| turnover | 换手率 | 越高越容易被成本吃掉 |
| transaction cost | 交易成本 | 回测必须扣除 |
对于初学者,最大回撤比年化收益更值得认真看。一个年化很高但回撤很深的策略,实盘中可能根本拿不住。
9. 加入样本切分
最小回测跑通后,再加入样本内和样本外对比。可以在 backtest.py 里加入:
def split_result(result: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
split_at = int(len(result) * 0.7)
in_sample = result.iloc[:split_at]
out_of_sample = result.iloc[split_at:]
return in_sample, out_of_sample
在 main() 中输出:
in_sample, out_of_sample = split_result(result)
print("in sample strategy", summarize(in_sample["net_strategy_return"], in_sample["turnover"]))
print("out of sample strategy", summarize(out_of_sample["net_strategy_return"], out_of_sample["turnover"]))
报告中必须写明:样本外表现是否明显变差。如果样本内很好、样本外很差,优先怀疑过拟合。
10. 参数敏感性
不要只试一个 lookback=20。至少比较:
lookback = 5, 10, 20, 60
cost_bps = 0, 5, 10, 20
简单做法是在 main() 中循环:
for lookback in [5, 10, 20, 60]:
for cost_bps in [0, 5, 10, 20]:
result = run_backtest(prices, lookback=lookback, cost_bps=cost_bps)
summary = summarize(result["net_strategy_return"], result["turnover"])
print(
f"lookback={lookback}, cost_bps={cost_bps}, "
f"annual_return={summary['annual_return']:.4f}, "
f"sharpe={summary['sharpe_ratio']:.4f}, "
f"max_drawdown={summary['max_drawdown']:.4f}"
)
如果只有某一个参数组合特别好,其它都很差,要警惕参数挖掘。
11. 报告模板
创建:
code reports/week15_backtest_notes.md
建议结构:
# Week 15 Backtest Notes
Strategy
- 策略名称:N 日动量
- 规则:过去 N 日收益为正则持有,否则空仓
- 执行假设:t 日收盘生成信号,t+1 日持仓
Data
- 数据来源:
- 日期范围:
- 频率:日频
Backtest settings
- lookback:
- cost_bps:
- benchmark:buy and hold
Metrics
- annual return:
- annual volatility:
- Sharpe:
- max drawdown:
- turnover:
Cost sensitivity
- 0 bps:
- 5 bps:
- 10 bps:
- 20 bps:
Observations
- 是否优于 buy and hold?
- 收益是否来自少数时间段?
- 成本后是否仍有效?
- 最大回撤是否能接受?
Limitations
- 模拟数据或单一标的
- 没有滑点
- 没有停牌、涨跌停、成交量限制
没有真实资金容量约束
12. 文件布局检查
运行:
pwd
ls
ls data figures reports src/quant_backtest_mini
至少应有:
data/prices.csv
src/quant_backtest_mini/__init__.py
src/quant_backtest_mini/backtest.py
src/quant_backtest_mini/metrics.py
figures/equity_curve.png
figures/drawdown.png
reports/week15_backtest_notes.md
reports/week15_backtest_daily_results.csv
13. 练习
- 把动量策略改成均值回复:价格低于 20 日均线一定比例则持有,否则空仓。
- 比较
lookback=5、20、60的净值曲线和最大回撤。 - 把交易成本从 0 bps 增加到 20 bps,观察 Sharpe 和年化收益变化。
- 加入一个简单止损规则,并检查是否只是降低收益、没有真正降低回撤。
- 把结果保存为 CSV,并在报告里找出策略收益最高和最低的 5 天。
- 把 benchmark 从 buy and hold 改成空仓,解释为什么量化报告通常需要合理 benchmark。
14. 验收检查
在项目根目录执行:
source .venv/bin/activate.fish
python -m quant_backtest_mini.backtest
python -c "from pathlib import Path; required = ['data/prices.csv', 'src/quant_backtest_mini/backtest.py', 'src/quant_backtest_mini/metrics.py', 'figures/equity_curve.png', 'figures/drawdown.png', 'reports/week15_backtest_daily_results.csv', 'reports/week15_backtest_notes.md']; missing = [p for p in required if not Path(p).exists()]; print('missing:', missing); raise SystemExit(1 if missing else 0)"
通过标准:
- 策略收益使用
position.shift(1),没有偷看当天收益。 - 回测同时输出扣成本前和扣成本后逻辑,报告中重点解释扣成本后。
- 至少计算 annual return、annual volatility、Sharpe、max drawdown、turnover。
- 至少有净值曲线和回撤图。
- 报告中有成本敏感性和局限性。
15. 常见错误
错误 1:忘记 shift(1)
如果直接写:
strategy_return = signal * asset_return
很可能偷看未来。除非你严格证明信号在收益发生前已知,否则应使用:
position = signal.shift(1)
错误 2:只看累计收益
累计收益高不代表好。必须同时看回撤、波动、Sharpe、交易成本。
错误 3:交易成本写成固定每天扣
成本应主要和交易有关,而不是每天都扣同样数额。最小版本可以用 turnover * cost_rate。
错误 4:把模拟数据结果当真实策略
模拟数据只用于练代码。真实策略必须使用真实数据、严格样本外验证和更完整成本模型。
错误 5:参数调到最好就结束
如果你试了很多参数,只报告最好的一个,就是参数挖掘。报告中要写参数敏感性。
16. 下一步
进入 Week 16:量化项目报告:指标、回撤、样本切分与局限性。下一周重点是把回测结果写成能被老师、同学或未来自己读懂的报告。
plain
喜欢的话,留下你的评论吧~