因子投资-实验03-构造多空因子组合

QUANT RESEARCH NOTE LAB 03

把每月股票信号变成三分组,并计算等权和市值加权的高减低 H-L 收益。实验使用 3 个形成月、每月 6 只股票,最重要的不是 qcut,而是用明确的 signal_date 与 return_date 断言“收益发生在信号之后”。

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

问题

把每月股票信号变成三分组,并计算等权和市值加权的高减低 H-L 收益。实验使用 3 个形成月、每月 6 只股票,最重要的不是 qcut,而是用明确的 signal_date 与 return_date 断言“收益发生在信号之后”。

最终实现接口:

make_long_short(frame, signal, ret, groups=3) → pd.DataFrame

输入是长表;输出每个形成月的低组、高组和 H-L 收益。

输入数据

每月包含 asset、signal、market_cap、next_return,并同时保存两个日期:

  • signal_date:信号和形成权重可得的日期;
  • return_date:next_return 对应持有期结束日期。

第一月沿用第 09 讲六股票例子;第二月反转信号顺序;第三月让高组表现差于低组。这样能验证函数不会假设 H-L 永远为正。

输入数据 3 行 × 3 列
形成月 signal 排列 A–F next_return 排列 A–F
2026-01-31 1,2,3,4,5,6 1%,-2%,0%,2%,4%,3%
2026-02-28 6,5,4,3,2,1 -1%,3%,2%,0%,1%,-2%
2026-03-31 1,3,5,2,4,6 2%,1%,-1%,3%,2%,4%

市值固定为 10、20、30、40、50、100,只用于演示权重差异,不代表真实市场规模。

逐步实现

1. 每期独立分组

PYTHON 可复现实验
1
2
3
# kb-skip
ranked = block[signal].rank(method="first")
block["group"] = pd.qcut(ranked, groups, labels=False)

rank(method=”first”) 给并列信号一个稳定顺序,适合确定性测试;真实研究中相同信号的拆分会引入任意性,更好的做法是预先规定断点与并列规则,并报告各组数量。

2. 计算两种组内权重

PYTHON 可复现实验
1
2
3
# kb-skip
equal_high = high[ret].mean()
value_high = np.average(high[ret], weights=high["market_cap"])

等权回答平均股票表现,市值加权回答按资本规模配置的表现。market_cap 必须在 signal_date 已知,不能用收益期末市值。

3. 构造 H-L 并保留两腿

输出不仅保存 H-L,还保留 high 与 low。只看差值会掩盖收益究竟来自多头有效、空头暴跌还是两者共同贡献。

完整代码

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
import numpy as np
import pandas as pd


def make_long_short(frame, signal, ret, groups=3):
required = {
"signal_date", "return_date", "asset",
signal, ret, "market_cap",
}
missing = required.difference(frame.columns)
if missing:
raise ValueError(f"missing columns: {sorted(missing)}")
if groups < 2:
raise ValueError("groups must be at least 2")
if not (frame["return_date"] > frame["signal_date"]).all():
raise ValueError("every return_date must be later than signal_date")
if frame.duplicated(["signal_date", "asset"]).any():
raise ValueError("signal_date and asset must be unique")

records = []
for date, original in frame.groupby("signal_date", sort=True):
block = original.copy()
if len(block) < groups:
raise ValueError("not enough assets for requested groups")
ranked = block[signal].rank(method="first")
block["group"] = pd.qcut(ranked, groups, labels=False)

low = block.loc[block["group"] == 0]
high = block.loc[block["group"] == groups - 1]
equal_low = low[ret].mean()
equal_high = high[ret].mean()
value_low = np.average(low[ret], weights=low["market_cap"])
value_high = np.average(high[ret], weights=high["market_cap"])

records.append({
"signal_date": date,
"return_date": block["return_date"].iloc[0],
"equal_low": equal_low,
"equal_high": equal_high,
"equal_hl": equal_high - equal_low,
"value_low": value_low,
"value_high": value_high,
"value_hl": value_high - value_low,
})

result = pd.DataFrame(records).sort_values("signal_date").reset_index(drop=True)
assert (result["return_date"] > result["signal_date"]).all()
assert np.allclose(
result["equal_hl"],
result["equal_high"] - result["equal_low"],
)
assert np.allclose(
result["value_hl"],
result["value_high"] - result["value_low"],
)
return result


assets = list("ABCDEF")
caps = [10, 20, 30, 40, 50, 100]
signal_dates = pd.to_datetime(["2026-01-31", "2026-02-28", "2026-03-31"])
return_dates = pd.to_datetime(["2026-02-28", "2026-03-31", "2026-04-30"])
signals = [
[1, 2, 3, 4, 5, 6],
[6, 5, 4, 3, 2, 1],
[1, 3, 5, 2, 4, 6],
]
future_returns = [
[0.01, -0.02, 0.00, 0.02, 0.04, 0.03],
[-0.01, 0.03, 0.02, 0.00, 0.01, -0.02],
[0.02, 0.01, -0.01, 0.03, 0.02, 0.04],
]

rows = []
for s_date, r_date, sig, ret in zip(
signal_dates, return_dates, signals, future_returns
):
for asset, cap, value, next_return in zip(assets, caps, sig, ret):
rows.append({
"signal_date": s_date,
"return_date": r_date,
"asset": asset,
"signal": value,
"market_cap": cap,
"next_return": next_return,
})

sample = pd.DataFrame(rows)
factor = make_long_short(sample, "signal", "next_return", groups=3)

assert np.isclose(factor.loc[0, "equal_hl"], 0.04)
assert np.isclose(factor.loc[1, "equal_hl"], 0.015)
assert np.isclose(factor.loc[2, "equal_hl"], -0.01)
print(factor.round(6).to_string(index=False))

预期输出

等权 H-L 依次为 4%、1.5%、-1%。第一月市值加权 H-L 为约 4.333%。第三月为负很重要:函数只实现定义,不保证因子有效。

输出列:

预期输出 6 行 × 2 列
列 含义
signal_date 分组与权重形成日期
return_date 持有期收益结束日期
equal_low / equal_high 两腿等权收益
equal_hl 等权高组减低组
value_low / value_high 两腿形成日市值加权收益
value_hl 市值加权高组减低组

三个月太短,不能计算有意义的年化、Sharpe 或 t 值。实验只验证组合形成逻辑。

断言

  1. 每一行 return_date 严格晚于 signal_date。
  2. signal_date、asset 联合唯一,防止同一股票重复计权。
  3. H-L 逐期精确等于 high-low。
  4. 第一月 H-L 与第 09 讲手算一致。
  5. 第三月 H-L 为负,证明实现没有强制输出正收益。

真实项目还应断言每期组数、每组数量、权重和、权重非负、形成日市值可得、收益缺失原因和可交易状态。

结果解释

等权与市值加权不同,不是实现误差,而是研究对象不同。市值加权降低小股票影响,但可能由少数大股票决定;等权展示平均股票效应,却隐含频繁再平衡和更高容量压力。

H-L 只是因子模拟组合。A 股空头组不必然可卖空,所以它可以用来做资产定价解释,却不能直接当作可执行策略收益。落地多头组合时,应分别评价 high 相对基准的主动收益、换手和约束。

每月只使用当月 signal 排名,收益发生在下一日期。若把 next_return 放入分组或使用期末市值加权,代码仍可能运行,却会产生未来函数;日期和列级断言比肉眼看净值更可靠。

常见故障

  • qcut 因大量并列值无法形成指定组数;先定义并列与断点规则。
  • 每月股票数少于 groups,或某组为空。
  • 市值为零、负数或缺失,导致权重无意义。
  • 用 return_date 的市值加权,偷偷引入收益本身。
  • merge 后一只股票出现多行,权重被重复计算。
  • 收盘信号用同一收盘收益,日期虽相同但日内顺序错误。
  • 只存 H-L,无法诊断哪条腿和哪些股票贡献结果。
  • 将研究多空组合直接视为可卖空 A 股组合。

练习

  1. 把 groups 改为 2 和 6,比较 H-L 与每组样本数。
  2. 给两只股票相同 signal,分别使用 first、average 和固定证券 ID 规则,观察分组。
  3. 加入 available_to_buy 与 available_to_sell,模拟涨停买不到、跌停卖不出后的实际持仓。
  4. 计算每月目标权重与漂移后权重的单边换手。
  5. 扩展函数支持参考断点,而非每期 qcut。
  6. 对多头 High 组选择可比基准,计算主动收益,不使用不可实现空头腿。

因子投资-实验03-构造多空因子组合
https://www.polarishaw.top/2026/07/17/因子投资-实验03-构造多空因子组合/
作者
Polaris
发布于
2026年7月17日
许可协议