工程地基:让判据变成会报错的测试
靠"记得小心"守不住任何一条判据,靠 pytest 才行
上一节给了你七个判据。现在我要说一句可能让你不舒服的话:你记不住它们。
我也记不住。写代码写到第三个小时,人一定会图省事——少写一个 .shift(1),把成本参数默认成 0,顺手在测试集上再看一眼结果。这不是态度问题,是人的常态。
所以专业的做法不是"记得小心",而是让机器在你犯错的那一秒报错。这一节我们造三道守卫,其中最重要的那道叫未来函数探测器——它用一段合成数据,专门抓"偷看未来"这个动作。
跑完这一节,你的项目在每次提交时都会被自动问一遍那七个判据里的三条。
飞行员每次起飞前都念一遍检查单。不是因为他们记不住,而是因为人在疲劳和熟练之后,一定会跳步——检查单的价值恰恰在于它不依赖人的状态。
代码里的检查单就是测试。它是你在清醒时,给未来那个疲惫的自己写下的约束。
一、项目骨架:一开始就分清"库"和"脚本"
关键的一条纪律:库里的东西必须可测试,notebook 里的东西不进库。绝大多数量化项目烂掉,都是从"这段分析先放 notebook 里,以后再整理"开始的。
pyproject.toml 里有一行值得单独说:
[tool.pytest.ini_options]
pythonpath = ["src"] # 让 tests 直接 import qlab,无需先安装
testpaths = ["tests"]
这样 pytest 一条命令就能跑,不需要先 pip install -e .——降低运行测试的门槛,是让测试真的被跑起来的前提。
二、一个刻意的设计决定:signal 不做 shift
def signal(close, fast, slow):
"""MA(fast) > MA(slow) 记 1,否则 0。注意:未做 shift,原样返回当日判断。"""
if fast >= slow:
raise ValueError(f"fast({fast}) 必须小于 slow({slow})")
f = close.rolling(fast).mean()
s = close.rolling(slow).mean()
return (f > s).astype(int)
我本可以让 signal() 内部自动 shift 一位,这样谁都不会犯未来函数的错。但我故意不这么做。
理由:如果 shift 藏在函数内部,那么"信号什么时候生效"这个决定就消失在了看不见的地方。有一天你需要日内信号、或者用开盘价成交,你会因为不知道内部做过什么而算错。
把关键决定留在明面上,然后用测试守住它——这是本课贯穿全程的取舍。
三、守卫一:未来函数探测器
这是本节最值得带走的东西。思路是造一段任何人都不可能预知的价格:
def spike_series(flat_days=30, jump=0.20, tail_days=10):
"""一直不动 → 某天突然大涨 20% → 之后又不动。"""
idx = pd.date_range("2020-01-01", periods=flat_days + 1 + tail_days, freq="B")
values = [100.0] * flat_days + [100.0 * (1 + jump)] * (1 + tail_days)
return pd.Series(values, index=idx, name="close")
在大涨那天之前,价格一动不动——任何只使用历史信息的方法,都不可能知道它要涨。于是这段数据成了一把尺子:
谁在大涨当天已经持有仓位,谁就偷看了未来。
def test_lookahead_guard():
close = spike_series()
spike_day = close.pct_change().idxmax()
raw = signal(close, 5, 20)
honest = raw.shift(1).fillna(0) # ✅ 次日生效
cheat = raw # ❌ 当日生效
assert honest.loc[spike_day] == 0, "老实版在大涨当日持仓 = 偷看了未来"
assert equity(close, honest).loc[spike_day] == pytest.approx(1.0)
assert equity(close, cheat).iloc[-1] / equity(close, honest).iloc[-1] == pytest.approx(1.20, rel=1e-6)
最后一行是这道守卫的灵魂:作弊版正好比老实版多赚 20%,一分不差——那根大阳线,就是它偷来的全部。
一道永远不会失败的测试,等于没有测试。
所以我给守卫本身也上了一道保险:把 .shift(1) 拿掉,守卫必须炸。这在 ECS 上实测过——
> assert honest.loc[spike_day] == 0, "老实版在大涨当日持仓 = 偷看了未来"
E AssertionError: 老实版在大涨当日持仓 = 偷看了未来
E assert np.int64(1) == 0
tests/test_guards.py:40: AssertionError
1 failed, 4 passed in 0.28s
写完任何一道守卫之后,都要故意破坏一次,确认它真的会响。没验证过会响的守卫,只是一段让你安心的自我欺骗。
四、另外四道守卫
| 测试 | 守哪条判据 | 怎么守 |
|---|---|---|
test_lookahead_guard | 判据一/三 | 大涨当日不允许持仓;作弊版必须正好多赚 20% |
test_lookahead_guard_catches_regression | —— | 给守卫上保险:把 shift 拿掉必须报错 |
test_cost_monotonic | 判据四 | 成本 0→5→10→20→50→100bp,年化必须单调不升 |
test_reproducible | 判据七 | 同一输入两次运行,净值曲线逐点相同 |
test_input_validation | —— | fast≥slow、负成本、索引不一致,必须立刻 ValueError |
test_cost_monotonic 值得多说一句:如果成本升高而年化不降,说明成本没有被计入换手——这是回测引擎里一个非常常见、又非常安静的 bug。它不会让程序崩溃,只会让你的策略看起来比实际好。
五道测试,0.26 秒跑完,不联网、不依赖任何行情源。这一点很关键:只有毫秒级的测试才会被真的挂在每次提交上。
platform linux -- Python 3.12.13, pytest-9.1.1
collected 5 items
tests/test_guards.py ..... [100%]
============= 5 passed in 0.26s =============
五、判据七的另一半:把环境钉死
测试保证逻辑对,依赖锁定保证别人能跑出同一个数字。用 uv(2026 年的事实标准,比 pip 快一个量级):
uv venv # 建虚拟环境
uv pip install pandas numpy pytest
uv pip freeze > requirements-lock.txt
本节实测环境的锁定结果:
numpy==2.5.1
pandas==3.0.5
pytest==9.1.1
python-dateutil==2.9.0.post0
⚠️ pandas 3.x 与 2.x 在若干默认行为上不同(例如某些
fillna的降级规则)。不锁版本的量化项目,过半年就会得出和当初不一样的数字——而你会以为是自己改坏了代码。
🔧 动手做:让守卫在你机器上报一次错(5 分钟)
在你自己的机器上跑一遍——不需要任何行情数据,五道守卫全部用合成数据:
uv venv && uv pip install pandas numpy pytest
pytest -q
# 5 passed in 0.26s
# 现在故意破坏:把 test_guards.py 里 honest 那行的 .shift(1) 删掉,再跑
pytest -q
# 1 failed, 4 passed
# AssertionError: 老实版在大涨当日持仓 = 偷看了未来
看到那条报错,你就拥有了这门课第一件真正的资产:一个会替你记住判据的机器。
📌 免责:本课为技术教学,本节所有数据均为合成数据,不涉及任何真实标的;不构成投资建议。
P0 章结束。你手里有三样东西:四级阶梯(知道一个回测能错在哪)、七个判据(知道该问什么)、五道守卫(让机器替你问)。
其中最该带走的是那把尺子:一段一直不动、突然大涨的合成价格。以后你接手任何一份回测代码,把这段数据灌进去,看它在大涨当日有没有仓位——三十秒就能判断作者有没有偷看未来。
下一节进入 P1 数据工程:akshare / tushare / baostock / yfinance 各自的限频、稳定性与坑,以及为什么境内项目不该把 yfinance 当主数据源。从这一节开始,我们碰真实数据——也开始碰真实的脏。
🔎 来源与核验· 4 条,点开核对
