跳转至

M03 数据与回测工程

层级:L1 策略与回测 | 优先级:★★★ | 预计学时:12 小时 对应原书:陈 第 3 章;黑箱 第 8–9 章 前置知识:M01 的夏普与 t 值;M02 的 shift(1)。费用和冲击的形状在 M13。 读书笔记:陈第 3 章 · 黑箱第 8–9 章 状态:reviewed 数据截至:2026-09|最近复核:2026-10-28


学习目标

  1. 说出复权价、未复权价和总收益指数各用在哪一步,以及后来的分红为什么不能改写更早的点。
  2. 列出至少八种回测偏差,并指出本仓库引擎里哪一条规则挡住其中的三条。
  3. 用手算索提诺、卡玛和港股换手成本,分母和「单边」的定义与 M00 一致。
  4. 用同一条 SPY 均线量化前视:去掉延迟之后夏普从 0.51 变成 1.77。这是 bug,不是改进。
  5. 把样本外只用一次写成流程。docs/workflow.md 开头写明,本轮不把「夏普 > 1」当作通用门槛。
  6. 用停牌、上市天数、价格、20 日成交额和退市状态滤出可交易标的池,并把杠杆 ETF 与 PTP 预提名单挡在 ETF 轮动池外面。

本章知识拓扑图

flowchart TB
    RAW[未复权成交价] --> ORD[下单股数与整手]
    ACT[已发生的分红拆股] --> TR[向前累加的总收益指数]
    TR --> SIG[信号只看到当时已有的点]
    SIG --> SCH["as_of 收盘 / effective_at 下一交易日"]
    SCH --> FILL[先卖后买;停牌不成交]
    FILL --> FEE[费用表是输入,不是券商合同]
    FEE --> MET[CAGR 夏普 回撤 换手]
    MET --> LOG[实验日志:尝试次数与数据版本]

🟢 入门层:数据先分清用途

1. 三类数据,三种难度

类型 内容 个人投资者最先碰到的洞
量价 开高低收、成交量、分红拆股 免费接口通常没有已退市股票
基本面 财报、估值、一致预期 修订后的数字会覆盖当时还没公布的版本
另类 新闻、搜索、卫星、卡消费 贵,而且交付时间往往晚于广告

学习阶段够用的公开来源:yfinance(显式 auto_adjust=True)、Kenneth French 因子库、AQR 数据集、SEC EDGAR 的提交日期、FRED。富途 OpenAPI 和 IB 的历史请求能和交易打通,也有额度和退市缺口。含退市股的付费库是研究预算,不是回测脚本里的一个开关。

yfinance 不是交易所官方接口。它会失效,也不会因为你要做动量就自动补上退市公司。

2. 复权价不能拿来下单

三列价格是同一本日记的三种抄法,不能篡改已经写上的那一页。 未复权价是柜台当天的挂牌价,下单和整手用它。 前复权价为了让图连上最新价,每次新分红都倒回去改十年前的那一格,只适合看图。 向前总收益指数在分红当天把再投资滚进雪球,已经写下的历史点位不再改,排名和动量用这一列。

除息、拆股会让未复权价跳空,持有人的财富不一定变。前复权把历史价改到「看起来连着最新价」,适合看图,但每来一次新分红,整段历史都会重写。后复权和总收益指数把分红再投资往后累加,已经记下的点不再改。

本仓库 total_return_index 向前构造:每个点只用到当天及之前的公司行为。策略用它排名,避免把拆股看成暴跌。成交仍用未复权价。港股还要整手,腾讯是 100 股一手,用复权价算股数会下出交易所拒掉的数量。

3. 回测在回答哪一个问题

回测不是预测明年收益。它回答:这条写死的规则,在这段已知数据上,按你声明的成交和费用,净值怎么走。声明没写的东西(用了当天收盘、股票池是今天的成分、费用表不是你的账户)都会把答案抬高。M02 的 SPY 均线在正确时点下 CAGR 7.40%。本章把同一条规则的时点改错,用来看抬高了多少。


🟡 进阶层:偏差、指标和本仓库的成交

4. 十种偏差

偏差 当时不可能成立的假设 怎么挡住
前视 用 t 的收盘决定 t 的收益 收益乘 hₜ₋₁;引擎用下一交易日 effective_at
幸存者 只用今天还上市的股票 股票池含退市;动量实验室的事后大市值池必须声明
时点 用尚未披露或后来修订的财报 用提交日;不知道披露日就滞后一个报告季
窥探 二百组参数只留下最好的一组 日志写尝试次数;M01 的 Bonferroni
过拟合 参数只有一个点好看 邻域要一起报告,不要只报冠军
成本 按收盘价、零费用成交 费用表和价差写进回测;小账户算最低收费
流动性 想买多少就买多少 参与率用过去的成交量,不用当天收盘后的总量
做空 任何股票都能借到、费率为零 券源和借券费是输入;借不到就不要做空
公司行为 拆股当天触发止损 信号走总收益指数,下单走未复权价
基准 小盘规则去比标普 500 同池等权或因子回归,见 M14

还有一种隐蔽的窥探:2024 年的论文说某因子在 2010–2024 年有效,你再在 2010–2024 上回测。这段对你已经不是样本外。

5. 前视的数值:同一条均线

M02 的月末 10 个月均线,窗口 2014-11-30 至 2026-09-30,143 个月,现金收益 0,夏普扣年化 2% 再乘 √12。

红线不是更好的策略。它在月末已经知道自己高于均线,又把这个月已经实现的涨幅算进持仓。夏普从 0.51 抬到 1.77,CAGR 从 7.40% 抬到 21.28%。旧讲义里「从 0.5 虚高到 2 以上」是数量级上的警告;本样本的实测是 0.51 和 1.77,不要把 2 写成这次计算的结果。

左:延迟与前视的净值。右:均线长度 6 到 18 的全样本夏普,10 个月在这窗里是谷而不是峰。

复现:uv run python knowledge/books/figures/M03/plot_lookahead.py。

右图是全样本敏感度,不是样本外。6 到 18 个月的夏普落在 0.51 到 0.71。10 个月是这组里的最低,18 个月是 0.71。没有一根针把夏普打到 2,所以它不是孤岛;它也不是平坦的高原。若因为 18 最高就只报告 18,你已经在同一段数据上挑过冠军,0.71 不能再叫样本外。

held = (month > sma).where(sma.notna())
honest = month.pct_change() * held.shift(1)
leaked = month.pct_change() * held  # 当月收盘同时决定当月收益

SmaCrossStrategy 内部不 shift。build_target_schedule 把信号标在收盘,把成交放到下一交易日。自己乘收益的脚本必须延迟;接进引擎后再延迟一次,会晚一个节拍。

6. 绩效公式

年化与夏普沿用 M01:日频用 √252,本章图是月频,用 √12,r_f 按 12 个月均摊。CAGR 用净值复利,不用算术平均冒充。

索提诺只把低于目标 T 的部分算进风险。本章手算用全部 n 个点做分母,目标以上的点贡献 0:

DD = √(1/n Σₜ₌₁ⁿ min(Rₜ − T, 0)²), Sortino = (¯R − T) / DD

有的软件只对低于目标的点求平均,或改用 n − 1。比较两个索提诺之前先对齐定义。Sortino & Price (1994, Journal of Investing 3(3)) 讨论的是下行偏差,不是「把亏损月的标准差再年化」这一种实现。

手算。 四期收益 3%, −2%, 4%, −1%,T = 0。

¯R = 0.01

DD = √((0 + 0.02² + 0 + 0.01²) / 4) = √0.000125 = 0.011180

Sortino = 0.01/0.011180 = 0.894

这是四期的比率,没有乘 √12。年化时分子和分母使用同一频率。

最大回撤沿用 M00:minₜ(Vₜ / max_(s≤t) Vₛ − 1)。卡玛比率:

Calmar = CAGR/|MDD|

手算。 CAGR 10%,最大回撤 −25%,卡玛 0.10/0.25 = 0.40。它把整段复利除以一次最深的坑,换窗口会跳。月末净值的回撤会漏掉月中的低点,M02 的 SPY 图已经是这个口径。

单边换手仍是成交额的一半除以净值。成本打在每一元成交上,所以拖累 = 2τ_单边c。

7. 换手成本

手算。 每月买入加卖出等于净值的 2 倍,即月单边换手 100%。每一元成交的费用加滑点为 0.2%。

年化拖累 = 12 × 2 × 0.002 = 4.8%

毛超额要先盖住 4.8%,再盖住回测乐观。盖住 4.8% 只说明成本项被抵消,不说明规则有样本外的正期望。

小账户的最低收费:

手算。 1 万美元,20 只股票,每只 500 美元,每月卖旧买新,每笔最低 2 美元。

20 × 2 × 12 × 2 = 960 美元 = 9.6%

改成 10 只、每季换一次:10 × 2 × 4 × 2 = 160 美元,占 1.6%。这里假设每次换仓都是一卖一买两笔,而且都触发最低收费。账户越小,持仓个数和换手越要先砍。

港股印花税仍是买卖各方 0.1%(2023-11-17 起,GovHK)。交易费、证监会征费、会财局征费以港交所现行收费表为准。教学上三者常被加总到大约 0.85 个基点(0.00565% + 0.0027% + 0.00015% = 0.0085%)。这个合计数会改,函数里要写成可替换的输入。

FeeSchedule 的文档写明:费率是输入,不是已经核对过的券商价目;个人所得税不在里面。label 必须说明它不是券商合同。压力测试只允许把费用乘以不小于 1 的倍数,避免「把成本调低来换夏普」。

8. 引擎里已经写死的三件事

src/quant/backtest/fills.py 把成交分成层,教学回测不要假装每一层都是实盘。

规则 代码里的含义
idealized 按开盘价成交。注释写明这不是部署证据
limit 开盘当中间价,让价不超过设定的基点;碰到最低价或最高价不算成交
参与率 用过去 20 个已完成交易日的成交量,不用当天最终成交量。历史不足 20 天就拒绝限价单
停牌 成交量为 0 的 K 线不是成交价
顺序 先卖后买。买进不能花掉一笔还没成交的卖出回款

这些规则挡住的是「用收盘总量证明自己当天买得进」和「停牌价被当成止损价」。它们不自动消除幸存者偏差,也不替你记录试了多少个参数。

9. 切分、滚动和这次均线搜索

固定切分:前面的年份开发,最后一段只碰一次。碰第二次,它就变成又一个训练集。

滚动前推:过去一段里挑选参数,下一段只使用,然后整窗往前移,把测试段接起来。挑选发生在测试段之前。

种子数据从 2014 年起,凑不齐「八年训练」。脚本用五年训练、下一日历年测试,参数仍是 6 到 18 个月。2020–2026 年共 81 个月:

年份 训练窗里夏普最高的月数
2020–2022 8
2023–2024 6
2025 12
2026(至 9 月,9 个月) 15

接起来的 CAGR 11.19%,夏普 0.74。同一 81 个月里,买入持有 CAGR 15.32%、夏普 0.82;固定 10 个月均线 CAGR 9.20%、夏普 0.62。滚动选择好过死守 10 个月,仍然落后买入持有,而且冠军长度从 8 跳到 15。全样本上 18 个月的夏普 0.71 是另一段窗口,不能和 0.74 比高低。

docs/workflow.md 开头写明:本轮不使用「夏普 > 1」作为通用门槛;文中表格里的夏普 > 1 是历史参考。模拟观察至少三个日历月。回测纪律里已经有:信号只用当时已有的数据、成交用下一交易日、费用和借券入账、时间切分、记录尝试次数。


🔴 深入层:过拟合概率和重抽样

10. PBO 在数什么

Bailey, Borwein, López de Prado & Zhu (2017, Journal of Computational Finance 20(4)) 的组合对称交叉验证把时间切成若干块,枚举哪些块做训练。PBO 是「样本内最优参数,在对应测试块上的表现低于中位数」的比例。它回答的是选择过程有多容易挑中噪声,不是再给策略一个夏普。

本章的 13 个均线长度已经是一组多次尝试。即便右图没有孤峰,报告其中最大的 0.71 仍然用掉了一次选择。M01 的 Bonferroni 用的是次数;PBO 用的是「冠军在没见过的块上是否还排在前一半」。块太少时这个比例本身很噪,不能把某一次算出来的 PBO 当成精确概率。

Purged K-Fold 在训练集和测试集之间删掉标签重叠的样本,避免未来收益漏进训练。那是 M16 的题目。普通 K 折打乱日期,会把 t + 1 的收益分进训练集。

11. 块自助法给出的是分布

把日收益独立打乱会拆掉波动聚集。块自助法保留块内顺序(Künsch, 1989)。它回答:在这块历史的依赖结构下,夏普和最大回撤的区间有多宽。块长是参数,不写进日志就不能和别人的区间比较。M01 没有为 SPY 另报一个未声明块长的区间,本章也不补一个。

12. 可交易标的池

第 8 节的参与率挡住的是「用当天还没走完的成交量证明买得进」。标的池要在信号之前就做完同一类过滤,否则回测里的股票在发单当天本来就不该出现。实施计划(docs/post_tutorial_research_backtest_futu_plan.md) 第 7 节的流动性红线是:订单名义金额不超过过去 20 个已完成交易日平均成交额的 0.1%,历史不足 20 日就拒绝。src/quant/risk/gate.py 里的 ADV_WINDOW 也是 20。当天最终成交量不能拿来做下单前检查。

个股留在池子里,要同时满足四条。当日未停牌,并且上市满 N 天,教学例子取 N = 60。收盘价达到最低门槛:美股大于等于 5.0 美元,用来躲开仙股;港股大于等于 2.0 港元,用来躲开细价老千股。过去 20 个已完成交易日的平均成交额足够大,使这一笔订单不超过 0.1% × ADV。已经公告退市或处于特殊处理的,剔除。

手算:单笔订单 5,000 美元,是 10 万净值的 5%。参与率上限 0.1% 时,

5000/0.001 = 5000000

美元。过去 20 日平均成交额低于 5000000 美元的标的,这一笔就过不了红线。等于 5000000 时刚好落在上限上,可以留下。订单和 ADV 必须先换成同一种货币。

ETF 轮动池在这道流动性红线之外再加三条。剔除杠杆或反向 ETF,例如 TQQQ、SQQQ 和其他 3 倍工具。剔除要交 PTP 预提税的代码;USO、UNG、UVXY、SVXY、DBC 是常见例子,名单以券商当时的清单为准,规则本身见 M18 第 13 节。同一类暴露只留一只,教学规则是留下 20 日成交额更大的那只,成交额相同则留下代码字母序更靠前的一只,避免 SPY 和 VOO 同时占着美股大盘。

# goal2-check
"""可交易标的池。只读教学表,不访问行情网关。"""

from decimal import Decimal

import pandas as pd

LEVERAGED_NAMES = {"TQQQ", "SQQQ"}
PTP_NAMES = {"USO", "UNG", "UVXY", "SVXY", "DBC"}
PRICE_FLOOR = {"USD": Decimal("5.0"), "HKD": Decimal("2.0")}


def filter_tradable_universe(
    frame: pd.DataFrame,
    *,
    order_notional: Decimal,
    min_listed_days: int,
    max_participation: Decimal = Decimal("0.001"),
) -> pd.DataFrame:
    """个股四要素,ETF 再加杠杆/反向、PTP 和同类暴露。ADV 用已完成的 20 日。"""
    if order_notional <= 0 or max_participation <= 0 or min_listed_days < 0:
        raise ValueError("order, participation and listing age are out of range")
    # 5,000 / 0.001 = 5,000,000。Decimal 避免二进制浮点把门槛算少 1 美元。
    min_adv = order_notional / max_participation
    kept: list[dict[str, object]] = []
    for rec in frame.to_dict("records"):
        if rec["suspended"] or rec["delisting_or_st"] or int(rec["adv_days"]) < 20:
            continue
        if Decimal(str(rec["adv_20"])) < min_adv:
            continue
        kind = rec["kind"]
        if kind == "stock":
            floor = PRICE_FLOOR.get(str(rec["currency"]))
            if floor is None or Decimal(str(rec["close"])) < floor:
                continue
            if int(rec["listed_days"]) < min_listed_days:
                continue
            kept.append(rec)
        elif kind == "etf":
            symbol = str(rec["symbol"])
            # 标志列漏标时,教学黑名单仍挡住点名的杠杆 ETF 和 PTP。
            levered = bool(rec["leveraged_or_inverse"]) or symbol in LEVERAGED_NAMES
            ptp = bool(rec["ptp"]) or symbol in PTP_NAMES
            if levered or ptp:
                continue
            kept.append(rec)
    stocks = [rec for rec in kept if rec["kind"] == "stock"]
    etfs = [rec for rec in kept if rec["kind"] == "etf"]
    best: dict[str, dict[str, object]] = {}
    for rec in etfs:
        key = str(rec["exposure"])
        prev = best.get(key)
        if prev is None:
            best[key] = rec
            continue
        new_adv = Decimal(str(rec["adv_20"]))
        old_adv = Decimal(str(prev["adv_20"]))
        # 成交额更高者留下;相同则代码字母序更前者留下。
        if (new_adv, _invert(str(rec["symbol"]))) > (old_adv, _invert(str(prev["symbol"]))):
            best[key] = rec
    rows = stocks + list(best.values())
    out = pd.DataFrame(rows)
    if out.empty:
        return out
    return out.sort_values("symbol").reset_index(drop=True)


def _invert(symbol: str) -> str:
    """字母序靠前的代码,在「更大者胜」的比较里数值更大。"""
    return "".join(chr(255 - ord(ch)) for ch in symbol)


# 列:代码 种类 停牌 上市天数 收盘 币种 ADV20 已完成天数 退市 杠杆 反向/PTP 暴露
# 0/1 表示假/真。暴露用 - 表示空。SQQQ 的杠杆标志故意写成 0,靠名称黑名单剔除。
SPEC = [
    "AAPL stock 0 1000 180 USD 20000000 20 0 0 0 -",
    "PENNY stock 0 1000 4.99 USD 9000000 20 0 0 0 -",
    "HK1 stock 0 1000 1.99 HKD 9000000 20 0 0 0 -",
    "HK2 stock 0 400 2.0 HKD 5000000 20 0 0 0 -",
    "HALT stock 1 1000 20 USD 9000000 20 0 0 0 -",
    "YOUNG stock 0 59 20 USD 9000000 20 0 0 0 -",
    "THIN stock 0 100 20 USD 4999999 20 0 0 0 -",
    "LIQ stock 0 100 6 USD 5000000 20 0 0 0 -",
    "ST stock 0 1000 20 USD 9000000 20 1 0 0 -",
    "SHORT stock 0 1000 20 USD 9000000 19 0 0 0 -",
    "TQQQ etf 0 1000 50 USD 1000000000 20 0 1 0 ndx3",
    "USO etf 0 1000 70 USD 100000000 20 0 0 1 oil",
    "SPY etf 0 1000 500 USD 30000000000 20 0 0 0 us_large",
    "VOO etf 0 1000 500 USD 10000000000 20 0 0 0 us_large",
    "SQQQ etf 0 1000 20 USD 1000000000 20 0 0 0 ndx3",
]
FIELDS = [
    "symbol", "kind", "suspended", "listed_days", "close", "currency", "adv_20",
    "adv_days", "delisting_or_st", "leveraged_or_inverse", "ptp", "exposure",
]
BOOLS = {"suspended", "delisting_or_st", "leveraged_or_inverse", "ptp"}
INTS = {"listed_days", "adv_days"}


def _parse_spec(line: str) -> dict[str, object]:
    parts = line.split()
    row: dict[str, object] = {}
    for name, raw in zip(FIELDS, parts, strict=True):
        if name in BOOLS:
            row[name] = raw == "1"
        elif name in INTS:
            row[name] = int(raw)
        elif name == "exposure" and raw == "-":
            row[name] = ""
        else:
            row[name] = raw
    return row


sample = pd.DataFrame([_parse_spec(line) for line in SPEC])
universe = filter_tradable_universe(
    sample,
    order_notional=Decimal("5000"),
    min_listed_days=60,
)
print("min_adv", int(Decimal("5000") / Decimal("0.001")))
print("kept", ",".join(universe["symbol"].tolist()))
assert int(Decimal("5000") / Decimal("0.001")) == 5000000
assert universe["symbol"].tolist() == ["AAPL", "HK2", "LIQ", "SPY"]

SQQQ 的标志列故意留成假,教学黑名单仍要把它剔除。SPY 与 VOO 同类,留下成交额更大的 SPY。THIN 的平均成交额是 4999999 美元,进不了池。

13. 本仓库研究数据与冻结快照工作流

研究数据不进 Git。目录是 data/processed/snapshots、data/processed/intraday、data/raw、data/cache、data/probe。多台开发机之间以 VPS 为中转,不在每台机器上各下一份同名文件。

冻结靠 SHA-256。快照注册之后,本机文件的哈希必须和注册记录一致。重新下载一份同名 Parquet,字节一变,哈希就变,回测不再是已经报告过的那一份样本。禁止用临时下载的同名文件去替换已注册快照。

三条命令分工,都在仓库根目录执行。用之前先取:uv run python scripts/ops/vps.py pull-research。本机已经和 VPS 一致时,这条会跳过。本机确认无误的新快照用 uv run python -m quant.cli data snapshot 生成;AGENTS.md 里的简写是 quant.cli data snapshot。生成并核对之后再发布:uv run python scripts/ops/vps.py push-research。VPS 上有本机没有的研究文件时,先拉再推。--force 会丢掉别的机器已经发布的数据,不能当成日常同步。

开发机没有接入 VPS 时,先按 deploy/README.md 做 vps.py init 和 vps.py enroll。这条链路只搬研究文件。它不连接 OpenD,也不写 ledger.db。


真实案例

案例 1:事后成分把基准抬高

动量实验室用今天的大市值名单回放 2008 年以后。等权基准的 CAGR 是 21.79%,策略 Iter 5 是 29.96%。实验室 README 把 21.79% 归因于幸存者偏差和事后入选,并要求只读相对同池的超额。Elton, Gruber & Blake (1996, RFS 9(4)) 估计的是共同基金数据库的幸存者偏差,大约每年 0.9 个百分点。那是基金样本的数字,不能挪来当成「个股动量每年因此虚高 0.9%」。个股偏差的本地证据就是这个 21.79% 的同池基准,而不是一个通用加成。

案例 2:发表后变薄,不是打五折的公式

McLean & Pontiff (2016, JF 71(1)):97 个预测变量,样本外平均低 26%,发表后平均低 58%。26% 是数据挖掘效应的上界,因为发表前已有人在交易。发表本身大约再解释 32 个百分点。旧讲义里的「回测收益打五折」容易被当成操作规则。论文的 58% 是这 97 个组合的平均降幅,不是你下一次回测应该乘的系数。用法是:预期更薄,把成本假设提高,并停止在同一段样本上继续搜。

案例 3:前视把 0.51 做成 1.77

见第 5 节的表。修正是 shift(1),或者把成交交给引擎的下一交易日。项目里的均线夹具走第二条路,路径是 src/quant/strategies/examples/sma_cross.py,不是旧讲义写的 src/quant/strategies/sma_cross.py。



方法论来源

概念 出处
研究流程 Narang 第 8–9 章;Chan 第 3 章
索提诺 Sortino & Price (1994), Journal of Investing 3(3)
基金幸存者偏差 Elton, Gruber & Blake (1996), RFS 9(4)
发表后衰减 McLean & Pontiff (2016), JF 71(1)
PBO Bailey 等 (2017), Journal of Computational Finance 20(4)
块自助 Künsch (1989), The Annals of Statistics 17(4)
本仓库成交 src/quant/backtest/fills.py、fees.py、data/corporate_actions.py
本仓库绩效 src/quant/backtest/metrics.py
流程门槛 docs/workflow.md 开头:本轮不以夏普 > 1 为通用门槛
## 💡 思考回顾与巩固

核心认知纠偏

误区 反例 用法
前复权价可以用来算股数 新分红会重写历史价;港股还有整手 排名用向前的总收益指数,下单用未复权价
去掉 shift(1) 夏普变好,说明规则更强 同一条均线从 0.51 变成 1.77 那是用当月收盘赚当月
全样本里 18 个月夏普最高,就是样本外最优 右图用了全部 143 个月。滚动选择的长度从 8 跳到 15 冠军参数和测试段不能是同一段
回测收益统一打五折 58% 是 97 个已发表预测变量的平均降幅 提高成本、停止续搜,不要乘一个万能系数
当天成交量可以证明限价单能成交 引擎的参与率只用过去 20 日,当天总量还没走完 历史不足 20 日就拒绝
池子里的股票只要有价格就能买 5,000 美元、参与率 0.1% 要求 20 日均成交额至少 5000000 美元 停牌、上市天数、价格和退市状态同时过关
夏普 > 1 是本项目当前门槛 docs/workflow.md 开头写明本轮不这么用 以文件开头的句子为准,不以后面的历史表为准
基金论文里每年 0.9% 的幸存者偏差适用于个股动量 0.9 个百分点是 Elton 等人的基金样本 个股用同池事后基准来说明,如 21.79%

费曼自测

  1. 为什么总收益指数必须向前累加?
  2. 前视那条红线的 21.28% 能不能当作策略上限?
  3. 滚动前推的 0.74 为什么不能和全样本 18 个月的 0.71 比?
  4. 一笔还没成交的卖出,为什么不能把回款用来买?
自测要点 1. 向后调整会在新的分红到达时改写旧价格。信号若用改写后的历史,就用到了当时不存在的公司行为。向前累加只让新的点包含新的分红。 2. 不能。21.28% 把「月末才知道的均线位置」乘进了已经结束的这个月。它是错误实现的净值。 3. 0.71 用了 2014–2026 全部月份来挑 18。0.74 只覆盖 2020–2026 的 81 个月,而且每年的长度是在那一年之前选定的。窗口不同,夏普不能直接排名。同一 81 个月上,买入持有的夏普是 0.82。 4. 卖出可能不成交,尤其是限价和停牌。先把未成交的现金算进购买力,会在回测里造出实盘没有的买入。引擎因此先处理卖出。

四维习题

A. 概念题

  1. 列出八种回测偏差,每种写一条检测动作。
  2. 为什么样本外段只能用一次?用过两次之后它变成什么?
  3. 前复权和向前的总收益指数各适合什么?下单用哪一个价格?
  4. idealized 成交为什么不能当作部署证据?

B. 计算题

  1. 四期收益 3%, −2%, 4%, −1%,目标为 0。按本章分母为 n 的定义计算索提诺。若只对两个负收益求均方根,比率变成多少?
  2. CAGR 10%,最大回撤 −25%。卡玛比率是多少?若回撤改用月中低点、从 −25% 加深到 −40%,卡玛变成多少?
  3. 每月双边成交额为净值的 2 倍,每元成交成本 0.2%。年化成本拖累是多少?
  4. 账户 1 万美元。方案甲:20 只股票、每月一卖一买、每笔 2 美元。方案乙:10 只、每季一卖一买、每笔 2 美元。年化成本占账户的比例各是多少?

C. 编程实战题

  1. 用 SPY 种子复现 10 个月均线的两条净值:shift(1) 与不延迟。打印两边的 CAGR 和夏普(r_f = 2%,月频 √12)。
  2. 对长度 6 到 18 计算全样本夏普,指出最低和最高的长度。说明这张图为什么还不是样本外。
  3. 用五年训练、下一年测试,把 2020–2026 接起来,并与同一月份上的买入持有、固定 10 个月比较。

D. 个人实战思考题

  1. 你要用 yfinance 做美股小盘动量。列出数据上的三项偏差,以及你能量化或至少声明的方式。
  2. 对照 docs/workflow.md,写十条回测检查。其中四条必须来自本仓库引擎已经实现的约束,而不是泛泛的「考虑成本」。
  3. 小账户 1 万美元、月换 20 只的成本占 9.6%。你的真实最低收费若不是 2 美元,这项比例会怎样改变你的持仓个数?
完整参考答案与解析 **A1.** 前视:收益是否乘了上一期信号。幸存者:股票池是否包含退市和当时的成分。时点:财报用的是提交日还是报表期。窥探:日志里的尝试次数是否包括删掉的参数。过拟合:邻域参数是否一起报告。成本:费用表是否单列最低收费和印花税。流动性:参与率是否用过去成交量。做空:借不到的股票是否仍被假设为空头。公司行为:拆股是否走总收益指数。基准:比较对象是否和股票池匹配。上面十条里任取八条即可,检测动作必须能在脚本或日志里指出来。 **A2.** 样本外的意义是挑选规则时没有看过它。看过一次并据此改参数,第二次的结果就含着这次修改。它变成新的训练集。真正没碰过的只剩之后的模拟盘和实盘。 **A3.** 前复权适合看连续的价格图,历史会被新的公司行为改写。向前的总收益指数适合算收益和排名。下单用未复权价,再按手数取整。 **A4.** `idealized` 按开盘价成交,不检查能否在量内成交,也不检查停牌。文件注释写明它不是部署证据。部署要看限价、过去成交量和费用表。 **B5.** 均值 0.01。四个点的下行平方和是 0.0004 + 0.0001 = 0.0005,除以 4 再开方得 0.011180,索提诺 0.894。只对两个负值:√(0.0005/2) = 0.015811,比率 0.01/0.015811 = 0.632。两个都对,只要写明分母。本章正文用 0.894 这一档。 **B6.** 0.10/0.25 = 0.40。加深后 0.10/0.40 = 0.25。月末回撤漏掉月中低点时,卡玛会被抬高。 **B7.** 月单边换手 = 2/2 = 1。年化拖累 = 12 × 2 × 0.002 = 0.048。等价地,每月成交额是净值的 2 倍,乘 0.2% 再乘 12。 **B8.** 甲:20 × 2 × 12 × 2 = 960 美元,占 9.6%。乙:10 × 2 × 4 × 2 = 160 美元,占 1.6%。这是最低收费触发时的上限结构。若佣金按金额、低于最低收费的门槛没被触发,比例会更低;小名义金额通常会触发。 **C9–C11.** 与 `plot_lookahead.py` 同一契约。延迟:CAGR 7.40%,夏普 0.51。前视:CAGR 21.28%,夏普 1.77。全样本夏普最低是 10 个月(0.51),最高是 18 个月(0.71)。这张扫描使用了全部月份,所以最高点不是样本外。五年训练的滚动结果:2020–2022 选 8,2023–2024 选 6,2025 选 12,2026 选 15。接起来 CAGR 11.19%、夏普 0.74(81 个月)。同一段买入持有 15.32% 与 0.82,固定 10 个月 9.20% 与 0.62。
import math
from pathlib import Path

import pandas as pd

RF = 0.02
px = pd.read_csv(
    Path("knowledge/Labs/momentum_plus_lab/seed_data/spy.csv"),
    parse_dates=["session"],
).set_index("session")["adj_close"].astype(float).sort_index()
month = px.resample("ME").last().dropna()


def sharpe(ret: pd.Series) -> float:
    std = float(ret.std(ddof=1))
    excess = float(ret.mean()) - RF / 12.0
    return excess / std * math.sqrt(12.0)


def cagr(ret: pd.Series) -> float:
    years = len(ret) / 12.0
    return float((1.0 + ret).prod() ** (1.0 / years) - 1.0)


def run(window: int, delay: bool) -> pd.Series:
    sma = month.rolling(window).mean()
    held = (month > sma).where(sma.notna())
    if delay:
        held = held.shift(1)
    frame = pd.concat({"mkt": month.pct_change(), "held": held}, axis=1).dropna()
    return frame["mkt"] * frame["held"].astype(float)


ok, leak = run(10, True), run(10, False)
common = pd.concat({"ok": ok, "leak": leak}, axis=1).dropna()
print("honest", round(cagr(common["ok"]), 4), round(sharpe(common["ok"]), 2))
print("leaked", round(cagr(common["leak"]), 4), round(sharpe(common["leak"]), 2))
**D12.** 三项:今天的小盘名单不含退市公司;拆股和分红若未复权会变成假突破;免费接口没有时点成分。声明方式:写明股票池是「下载日仍存在的名单」,收益只用复权价并 `shift(1)`,不把结果叫成无偏的小盘溢价。估计偏差的本地做法是同时报告一个含有已知退市或被剔除名字的子集,而不是引用基金论文的 0.9%。 **D13.** 引擎四条:成交用未复权价,排名用向前的总收益指数;`idealized` 不作为部署证据;参与率用过去 20 日成交量,当天总量不用;先卖后买,零成交量不成交。流程四条:时间切分且测试段只用一次;尝试次数含弃用参数;费用表带 label,压力倍数不小于 1;印花税按买卖各方 0.1% 另计。剩余两条:信号时间戳不晚于 `as_of`;基准与股票池匹配,并声明幸存者。`docs/workflow.md` 已有一份清单,新写的十条是补引擎约束,不是替换文件开头的门槛说明。 **D14.** 把 2 美元换成你的券商最低收费 f,甲方案的比例是 20 × 2 × 12 × f/10000。若 f = 1 美元,比例降到 4.8%,仍高于很多月频规则的净超额。持仓个数要减到「最低收费 × 每年笔数」低于你能接受的拖累。笔数等于持仓数乘每年换仓次数乘 2。先定拖累上限,再反解持仓数,而不是先复制一个 20 只的组合。

📌 章节知识总结

一句话核心洞察

  1. 排名用向前的总收益指数,下单用未复权价。新的分红不能改写旧的信号。
  2. 同一条 10 个月均线,前视把夏普从 0.51 抬到 1.77。红线不是业绩。
  3. 全样本冠军和滚动测试的夏普不能跨窗口比较。这次滚动仍然跑输买入持有。
  4. 成本打在每一元成交上。月双边 2 倍净值、每元 0.2%,一年是 4.8%。1 万美元月换 20 只、每笔 2 美元,是 9.6%。
  5. 发表后平均下降 58% 是那 97 个预测变量的结果,不是一个可以乘到任何回测上的系数。

公式与参数速查

名称 表达式或数值 前提
诚实均线 Rₜ hₜ₋₁ 均线不足时保持缺失
本章前视对照 夏普 1.77 对 0.51;CAGR 21.28% 对 7.40% 2014-11 至 2026-09,现金收益 0,r_f = 2%
长度 6–18 夏普 0.51–0.71;10 个月最低,18 个月 0.71 全样本,不是样本外
五年滚动 接起 CAGR 11.19%,夏普 0.74 2020–2026 共 81 个月;同期买入持有夏普 0.82
索提诺手算 0.01/0.011180 = 0.894 分母含全部 n 点;未年化
卡玛 CAGR/|MDD| 10% 与 25% 时为 0.40
换手拖累 2τ_单边c 月单边换手 1、c = 0.2% 时为 4.8%/年
小账户 20 只月换、每笔 2 美元 → 9.6% 1 万美元;每笔都触发最低收费
港股印花税 买卖各方 0.1% 2023-11-17 起
参与率回看 20 个已完成交易日 ADV_LOOKBACK;不用当天总量
单笔 5,000 美元的 ADV 门槛 5000000 美元 5000/0.001;10 万净值的 5%,参与率 0.1%
价格门槛 美股 ≥ 5.0 美元,港股 ≥ 2.0 港元 个股池;ETF 另剔杠杆/反向和 PTP
基金幸存者偏差 约每年 0.9 个百分点 Elton 等的基金样本,不是个股公式

跨模块联结

本章结论 下游 工程落点
时点与前视 M02 的均线、M06 的因子 src/quant/core/signals.py
总收益指数向前构造 排名与复权 src/quant/data/corporate_actions.py
费用是输入 M13 src/quant/backtest/fees.py
成交约束 M15 的模拟盘 src/quant/backtest/fills.py
可交易标的池 发单前的流动性与 PTP src/quant/risk/gate.py 的 ADV_WINDOW;M18
尝试次数 M08 docs/workflow.md
事后股票池 读实验室时先读声明 knowledge/Labs/momentum_lab/README.md 第五节

学习指引

概念卡片:前视偏差与幸存者偏差

自检清单

  • 能解释 0.51 与 1.77 的差别来自时点,不来自参数
  • 能手算索提诺 0.894 和年化成本 4.8%
  • 能说出参与率为什么不用当天成交量
  • 能说明滚动夏普 0.74 与全样本 0.71 不能比
  • 知道本轮流程不以夏普 > 1 为门槛
  • 下单价和排名价在笔记里分成两列