因子投资-实验05-价值与动量分层回测

QUANT RESEARCH NOTE LAB 05

构造 24 个形成月、30 只股票的确定性面板,计算 EP 和 12-1 动量,分别做单变量三分组,再做独立 3×3 双排序。输出单因子 H-L、控制另一因子后的价值与动量边际收益,以及等权多空目标组合的换手。

类型动手实验
模块研究实验
阅读约 13 分钟
材料9 节 · 3 段代码

问题

构造 24 个形成月、30 只股票的确定性面板,计算 EP 和 12-1 动量,分别做单变量三分组,再做独立 3×3 双排序。输出单因子 H-L、控制另一因子后的价值与动量边际收益,以及等权多空目标组合的换手。

这个实验重点验证三件事:信号只影响下一期收益;独立双排序每期 9 个单元非空;联合控制后的边际收益与单变量收益不是同一个对象。

输入数据

代码先生成 37 个月收益历史。前 13 个月只用于建立动量窗口;从索引 12 到 35 形成 24 个信号月,并把索引 t+1 作为未来收益。

  • 30 只股票分成三种基础趋势,保证动量横截面可识别;
  • EP 组随月份轮换,产生持仓变化;
  • future_return 由当期 EP 状态、历史动量状态和小幅确定性噪声组成;
  • 所有日期、股票、公式和参数都在代码中,不依赖外部数据。

这是算法测试夹具,不是模拟真实 A 股分布。人工嵌入的收益关系不能作为因子有效证据。

逐步实现

1. 计算 12-1 动量

PYTHON 可复现实验
1
2
# kb-skip
momentum = np.prod(1 + returns[t - 12:t - 1, :], axis=0) - 1

切片包含 t-12 至 t-2,共 11 个月,排除 t-1 最近月。代码同时保存 signal_date 和 return_date,并断言后者更晚。

2. 独立三分组

PYTHON 可复现实验
1
2
3
4
5
6
7
# kb-skip
block["ep_group"] = pd.qcut(
block["ep"].rank(method="first"), 3, labels=False
)
block["mom_group"] = pd.qcut(
block["momentum"].rank(method="first"), 3, labels=False
)

两个断点分别从各自边际分布产生,故为独立双排序。先 EP 后在组内排动量会变成条件双排序,结果不能混称。

3. 单因子与双排序边际收益

单因子 EP H-L 直接比较 EP 高低组。双排序价值边际收益先在每个动量组内算高 EP 减低 EP,再对三个动量组等权平均;动量边际收益反之。

4. 换手

多空目标权重在高组为 +1/n(H),低组为 -1/n(L)。从第二个月开始:

这是目标权重直接比较,未考虑持有期收益造成的权重漂移;真实成本实验会在实验 07 修正。

完整代码

PYTHON 可复现实验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import numpy as np
import pandas as pd


def target_weights(block, group_col):
high = block[group_col] == 2
low = block[group_col] == 0
weights = pd.Series(0.0, index=block["asset"])
weights.loc[block.loc[high, "asset"]] = 1.0 / high.sum()
weights.loc[block.loc[low, "asset"]] = -1.0 / low.sum()
return weights.sort_index()


def analyze_month(block):
block = block.copy()
block["ep_group"] = pd.qcut(
block["ep"].rank(method="first"), 3, labels=False
)
block["mom_group"] = pd.qcut(
block["momentum"].rank(method="first"), 3, labels=False
)

cells = block.groupby(
["ep_group", "mom_group"], observed=True
)["next_return"].mean().unstack()
if cells.shape != (3, 3) or cells.isna().any().any():
raise ValueError("all 3x3 cells must be non-empty")

ep_groups = block.groupby("ep_group", observed=True)["next_return"].mean()
mom_groups = block.groupby("mom_group", observed=True)["next_return"].mean()

return {
"ep_hl": ep_groups.loc[2] - ep_groups.loc[0],
"mom_hl": mom_groups.loc[2] - mom_groups.loc[0],
"double_ep": (cells.loc[2] - cells.loc[0]).mean(),
"double_mom": (cells[2] - cells[0]).mean(),
"ep_weights": target_weights(block, "ep_group"),
"mom_weights": target_weights(block, "mom_group"),
}


n_assets = 30
assets = [f"S{i:02d}" for i in range(n_assets)]
dates = pd.date_range("2023-01-31", periods=37, freq="ME")
returns = np.zeros((37, n_assets))

for t in range(13):
for i in range(n_assets):
trend = 0.008 * ((i % 3) - 1)
returns[t, i] = trend + 0.001 * np.sin(0.7 * t + i)

rows = []
for t in range(12, 36):
momentum = np.prod(1 + returns[t - 12:t - 1, :], axis=0) - 1
ep_state = np.array([((i // 10) + t) % 3 for i in range(n_assets)])
market_cap = 100 + 5 * np.arange(n_assets) + 2 * t
ep = 0.03 + 0.02 * ep_state + 0.00001 * np.arange(n_assets)
earnings = ep * market_cap

mom_state = np.array([(i % 3) - 1 for i in range(n_assets)])
next_return = (
0.002
+ 0.006 * (ep_state - 1)
+ 0.008 * mom_state
+ 0.001 * np.sin(0.4 * t + np.arange(n_assets))
)
returns[t + 1, :] = next_return

for i, asset in enumerate(assets):
rows.append({
"signal_date": dates[t],
"return_date": dates[t + 1],
"asset": asset,
"market_cap": market_cap[i],
"earnings_ttm": earnings[i],
"ep": earnings[i] / market_cap[i],
"momentum": momentum[i],
"next_return": next_return[i],
})

panel = pd.DataFrame(rows)
assert len(panel) == 24 * 30
assert (panel["return_date"] > panel["signal_date"]).all()
assert not panel.duplicated(["signal_date", "asset"]).any()

records = []
previous_ep = None
previous_mom = None
for date, block in panel.groupby("signal_date", sort=True):
result = analyze_month(block)
ep_turnover = np.nan
mom_turnover = np.nan
if previous_ep is not None:
ep_turnover = 0.5 * (result["ep_weights"] - previous_ep).abs().sum()
mom_turnover = 0.5 * (result["mom_weights"] - previous_mom).abs().sum()
records.append({
"signal_date": date,
"ep_hl": result["ep_hl"],
"mom_hl": result["mom_hl"],
"double_ep": result["double_ep"],
"double_mom": result["double_mom"],
"ep_turnover": ep_turnover,
"mom_turnover": mom_turnover,
})
previous_ep = result["ep_weights"]
previous_mom = result["mom_weights"]

monthly = pd.DataFrame(records)
summary = monthly.drop(columns="signal_date").mean()

assert len(monthly) == 24
assert monthly[["ep_hl", "mom_hl", "double_ep", "double_mom"]].notna().all().all()
assert np.isfinite(summary).all()

print(summary.round(6).to_string())
print(monthly.head().round(6).to_string(index=False))

预期输出

程序应生成 720 行点时面板、24 行月度结果。ep_hl、mom_hl、double_ep、double_mom 均应为有限数,每期 3×3 的 9 个单元都非空。

由于生成过程给 EP 状态和趋势状态分别加入正方向收益,四类平均收益应为正;具体小数由确定性正弦噪声决定。EP 状态每月轮换,因此 ep_turnover 显著高于零;动量状态更稳定,mom_turnover 应较低。

不要把“预期为正”写成对程序的强制断言。真正的回测函数必须允许因子失效;这里只断言结构、日期、完整性和有限数。

断言

  1. 面板恰有 24×30=720 行。
  2. 每行 return_date 严格晚于 signal_date。
  3. signal_date、asset 联合唯一。
  4. 每月独立 3×3 双排序的 9 个单元都非空。
  5. 四类月收益无缺失且为有限数。
  6. 换手除首月外可计算,摘要均为有限数。

真实数据还需断言财报公告早于 signal_date、动量月份连续、EP 分母有效、每组市值覆盖、停牌和退市收益处理、权重和与成交可行性。

结果解释

ep_hl 是不控制动量的价值差,double_ep 是在三个动量组内分别比较价值后再平均。两者接近说明本样本中 EP 与动量混杂较小;差异大则说明单变量价值收益包含动量暴露或交互。

独立双排序保证 EP 与动量各用自己的全市场断点,但不能保证每个交叉单元规模相同。真实样本应保存单元数量、权重、市值和行业覆盖;单个很小单元会让边际收益不稳定。

换手使用目标权重,没有考虑收益漂移、无法成交和交易成本,因此是下界式教学指标。ep_state 人工轮换导致高换手,说明一个统计关系即使为正,也可能在净收益层面失败。

12-1 动量来自真实滚动历史,不是直接用 future_return 排序。收益 t+1 写入后,只会在未来更晚的形成窗口中进入历史;跳过最近月进一步隔开同月机械关系。

常见故障

  • rolling 切片包含最近月或未来月。
  • EP 使用 return_date 市值或尚未公告盈利。
  • 独立排序被误实现为先 EP 后组内动量。
  • qcut 遇并列值时偷偷减少组数。
  • 交叉单元为空仍用 skipna 平均,隐藏问题。
  • 换手只比较股票集合,不比较权重。
  • 第一月没有前期权重却填成零换手,拉低平均。
  • 双排序边际收益按股票数而非单元组合等权,改变定义。
  • 人工数据中的正收益被误解为经验发现。

练习

  1. 将独立双排序改为“先 EP、后组内动量”的条件排序,比较结果。
  2. 将单元等权平均改为按单元市值加权,说明经济问题变化。
  3. 让 EP 与动量完全相关,观察哪些 3×3 单元为空,并解释独立识别失败。
  4. 用收益漂移后的再平衡前权重重算换手。
  5. 加入单边 20 基点成本,比较单因子和双排序净收益。
  6. 把人工收益中的 EP 系数设为零,验证程序仍能正常输出而不强制正收益。

因子投资-实验05-价值与动量分层回测
https://www.polarishaw.top/2026/07/17/因子投资-实验05-价值与动量分层回测/
作者
Polaris
发布于
2026年7月17日
许可协议