Week 15:Mini 量化回测、动量规则与交易成本

Published 2026-07-26 15:00 4397 words 22 min read

This post is not yet available in English. Showing the original.
Week 15:Mini 量化回测、动量规则与交易成本。fish-first 终端教学,面向 CachyOS、VS Code、uv 和 Python 学习路线。
Oh My Pi / weekly tutorial / week15-quant-backtest-mini
miku@cachyos:~/Code/python-learning$ omp teach week15-quant-backtest-mini --fish-first --step-by-step
source562 行教学文档
weekWeek 15
shellfish-first 命令版
backlink20 周计划

Week 15:Mini 量化回测、动量规则与交易成本

返回总计划:USTC 统计 + AI / 量化 20 周成长计划

Week 15 的目标是做一个最小可用回测:从价格数据出发,计算收益率,生成交易信号,转成持仓,加入交易成本,最后输出收益、波动、最大回撤、Sharpe、换手率等指标。

本文命令默认使用 fish shell。激活虚拟环境请使用:

ompfish
source .venv/bin/activate.fish

0. 本周详细教学:语法、规范、验收

本节不是追加在尾部的复习,而是本周正文的入口。先读这里,再做后面的命令和项目。

0.1 本周真正要学会什么

维度要求
知识点signal、position、shift(1)、成本、turnover
代码语法能从空文件写出本周核心脚本,而不是只复制运行
程序规范函数拆分、路径清楚、输入输出明确、错误能解释
交付物projects/quant-backtest-mini/src/backtest.py
验收方式从 fish 终端运行命令,得到可复查的文件或指标

0.2 代码语法精讲

下面的代码不是最终答案,而是本周必须理解的最小骨架:

omppython
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 本周和主线的连接

1. 本周学习目标

完成本周后,你应该能做到:

  1. 读取价格数据并计算日收益率。
  2. 实现一个简单动量策略:过去 N 日收益为正则持有,否则空仓。
  3. 正确处理信号和持仓的时间差,避免用当天收盘信号赚当天收益。
  4. 加入按换手计算的交易成本。
  5. 计算策略净值、最大回撤、年化收益、年化波动、Sharpe、turnover。
  6. 把代码拆成 backtest.pymetrics.py,形成一个可复用的小项目。

本周建议文件布局:

ompprompt
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() 的含义。
  • 年化指标只是约定换算,不代表未来收益保证。

创建项目:

ompfish
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 的数据,可以复制:

ompfish
cp ../week14-time-series-validation/data/prices.csv data/prices.csv

如果没有,创建 src/make_sample_prices.py

omppython
from pathlib import Path

import 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”)

运行:

ompfish
python src/make_sample_prices.py

检查:

ompfish
python -c "import pandas as pd; df = pd.read_csv('data/prices.csv'); print(df.head()); print(df.tail()); print(df.shape)"

真实项目中,数据可以来自指数、ETF、股票或课程提供的数据。本周重点不是数据接口,而是回测逻辑。

4. 回测里的时间顺序

假设策略规则是:

ompprompt
如果过去 N 日收益为正,则持有;否则空仓。

关键问题:什么时候知道信号,什么时候产生收益?

本教程采用保守设定:

ompprompt
t 日收盘后:计算过去 N 日收益,得到 t 日信号
t+1 日:根据信号持仓,获得 t+1 日收益

所以代码里必须做:

omppython
position = signal.shift(1)
strategy_return = position * asset_return

如果不 shift(1),就相当于用今天收盘后才知道的信号,赚了今天从开盘到收盘的收益,这是未来函数。

5. 实现风险指标 metrics.py

创建 src/quant_backtest_mini/metrics.py

omppython
import numpy as np
import pandas as pd

TRADING_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

omppython
from pathlib import Path

import 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=[&quot;asset_return&quot;])

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(&quot;reports/week15_backtest_daily_results.csv&quot;)
print(&quot;saved figures and reports/week15_backtest_daily_results.csv&quot;)

if name == “main”: main()

运行:

ompfish
python -m quant_backtest_mini.backtest

如果提示找不到包,确认你在项目根目录,并且包在 src/quant_backtest_mini/ 下。uv init 通常会配置好 src 布局;如果没有,可以先用下面命令运行:

ompfish
PYTHONPATH=src python src/quant_backtest_mini/backtest.py

推荐最终使用模块方式运行,因为 Week 17 工程化会继续整理 CLI 入口。

7. 理解交易成本

本教程使用基点 bps:

ompprompt
1 bps = 0.01%
5 bps = 0.05%

交易成本计算:

omppython
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。频繁交易会产生更高成本。

必须比较:

ompprompt
gross_strategy_return:未扣成本
net_strategy_return:扣成本后
benchmark_return:买入并持有

如果策略只在未扣成本时有效,扣成本后失效,就不能说它有交易价值。

8. 风险指标怎么读

指标 含义 注意点
annual return 年化收益 样本短时容易夸大
annual volatility 年化波动 只看波动不看回撤不够
Sharpe ratio 单位风险收益 不稳定,受极端值影响
max drawdown 最大回撤 很贴近真实心理压力
turnover 换手率 越高越容易被成本吃掉
transaction cost 交易成本 回测必须扣除

对于初学者,最大回撤比年化收益更值得认真看。一个年化很高但回撤很深的策略,实盘中可能根本拿不住。

9. 加入样本切分

最小回测跑通后,再加入样本内和样本外对比。可以在 backtest.py 里加入:

omppython
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() 中输出:

omppython
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。至少比较:

ompprompt
lookback = 5, 10, 20, 60
cost_bps = 0, 5, 10, 20

简单做法是在 main() 中循环:

omppython
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. 报告模板

创建:

ompfish
code reports/week15_backtest_notes.md

建议结构:

ompprompt
# 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. 文件布局检查

运行:

ompfish
pwd
ls
ls data figures reports src/quant_backtest_mini

至少应有:

ompprompt
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. 练习

  1. 把动量策略改成均值回复:价格低于 20 日均线一定比例则持有,否则空仓。
  2. 比较 lookback=52060 的净值曲线和最大回撤。
  3. 把交易成本从 0 bps 增加到 20 bps,观察 Sharpe 和年化收益变化。
  4. 加入一个简单止损规则,并检查是否只是降低收益、没有真正降低回撤。
  5. 把结果保存为 CSV,并在报告里找出策略收益最高和最低的 5 天。
  6. 把 benchmark 从 buy and hold 改成空仓,解释为什么量化报告通常需要合理 benchmark。

14. 验收检查

在项目根目录执行:

ompfish
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)

如果直接写:

omppython
strategy_return = signal * asset_return

很可能偷看未来。除非你严格证明信号在收益发生前已知,否则应使用:

omppython
position = signal.shift(1)

错误 2:只看累计收益

累计收益高不代表好。必须同时看回撤、波动、Sharpe、交易成本。

错误 3:交易成本写成固定每天扣

成本应主要和交易有关,而不是每天都扣同样数额。最小版本可以用 turnover * cost_rate

错误 4:把模拟数据结果当真实策略

模拟数据只用于练代码。真实策略必须使用真实数据、严格样本外验证和更完整成本模型。

错误 5:参数调到最好就结束

如果你试了很多参数,只报告最好的一个,就是参数挖掘。报告中要写参数敏感性。

16. 下一步

进入 Week 16:量化项目报告:指标、回撤、样本切分与局限性。下一周重点是把回测结果写成能被老师、同学或未来自己读懂的报告。

plain

If you enjoyed this, leave a comment~

© 2026 江无没有月 @miku
Powered by theme astro-koharu · Inspired by Shoka