← 返回目录
p0.3动手⏱ 约 15 分钟

工程地基:让判据变成会报错的测试

靠"记得小心"守不住任何一条判据,靠 pytest 才行

🔎 最后验证 2026-07📚 来源: 本节 5 个测试于 2026-07 在 ECS 实测通过(0.26s);故意改坏后守卫报错输出见 code/outputs/pytest-broken.txt🧰 Python 3.12.13、uv 0.12.0、pytest 9.1.1、pandas 3.0.5、numpy 2.5.1
为什么学这个

上一节给了你七个判据。现在我要说一句可能让你不舒服的话:你记不住它们。

我也记不住。写代码写到第三个小时,人一定会图省事——少写一个 .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 条,点开核对
本节每个关键论断都对应一个可追溯的来源 —— 这是本课程"靠谱、不过时"的底线。
「5 个守卫测试在 Python 3.12.13 + pytest 9.1.1 + pandas 3.0.5 环境下 0.26s 全部通过」
📚 本节 code/outputs/pytest-pass.txt,2026-07 于 ECS 实测✓ 已核验 2026-07
「删掉 .shift(1) 后 test_lookahead_guard 报错「老实版在大涨当日持仓 = 偷看了未来」,1 failed / 4 passed」
📚 本节 code/outputs/pytest-broken.txt 实测✓ 已核验 2026-07
「锁定环境:numpy 2.5.1 / pandas 3.0.5 / pytest 9.1.1 / python-dateutil 2.9.0.post0」
📚 uv pip freeze 输出 code/outputs/requirements-lock.txt✓ 已核验 2026-07
「uv 0.12.0 提供 venv 创建与依赖锁定;pyproject.toml 的 tool.pytest.ini_options.pythonpath 可免安装直接 import 源码包」
📚 uv 与 pytest 官方文档✓ 已核验 2026-07
智图软件的赞赏码
都看到这了,打个赏呗!
接下来 · p1.1
多源数据版图与选型
继续读下一节 →