先理解新的研究顺序
你指出的问题是正确的:如果第一个 Notebook 就处理 SEC/XBRL、CIK/ticker、公告日期和 Book Equity,那么你首先学到的是数据工程,而不是因子研究。
一个更像 Quant Research 项目的顺序应该是:
因此项目现在拆成三阶段:
| 阶段 | Notebook | 目标 |
|---|---|---|
| 阶段一:经典 survey | 00 到 07 | 用透明定义理解 Momentum、Value、Size、Risk、Liquidity、Quality、Technical;先得到一张因子地图。 |
| 阶段二:研究设计 | 08_cross_factor_comparison、09_new_factor_hypothesis | 比较相关性、暴露和边际贡献,再提出有经济动机的新因子,而不是凭空增加指标。 |
| 阶段三:正式验证 | 10_sec_data_audit 到 13_final_robustness_and_oos | 处理 SEC point-in-time 数据,构造 B/M,做成本、行业控制和 OOS。 |
00 只做市场数据的最小审计;01 只做一个经典主因子 MOM_12_1。MOM_3_1、MOM_6_1、REV_1M 先作为最后的有限敏感性分析,不让第一天陷入参数组合。开始前:准备环境和最小数据接口
你已经有项目目录:
/home/ubuntu/projects/factor-pricing-research/
在终端执行:
cd /home/ubuntu/projects/factor-pricing-research python3 -m venv .venv source .venv/bin/activate python -m pip install --upgrade pip python -m pip install jupyterlab pandas numpy matplotlib seaborn scipy statsmodels pyarrow pyyaml
notebooks/、configs/、data/、src/。本教程不要求你现在下载 SEC 全市场数据。如果 Qlib 数据已经准备好,市场快照至少应有下面这些列:
instrument date close return market_cap industry exchange
如果还没有 Qlib 数据,先创建一个小型本地 parquet 快照完成 Notebook 结构验证,并在 data/README.md 中标记“仅用于管线测试”。不要把测试数据的结果写成正式研究结论。
Notebook 00:最小市场数据审计
打开 notebooks/00_setup_and_data_audit.ipynb。这个 Notebook 的任务只有一句话:
它应该控制在约 15-25 个 cell,半天到一天跑通。它暂时不做:
- SEC API 和 Company Facts。
- CIK/ticker 映射。
- XBRL tag 选择和 Book Equity。
- B/M 计算和正式价值组合。
- 复杂的成本、容量、beta 中性和 OOS。
Notebook 00:按顺序创建 cell
写研究契约
# 00 — Setup and Data Audit 本 notebook 只审计 Qlib/市场数据,并生成下一期收益标签。 - Universe: 当前市场数据快照中的股票 - Frequency: 月频 - Sample: 数据快照实际覆盖范围;暂不宣称无 survivorship bias - Formation date: 月末 - Label: formation date 之后的下一月收益 - 本 notebook 不下载 SEC,不构造 B/M,不进行参数搜索 ## Questions 1. 每个 instrument/date 是否只有一行? 2. date 是否按时间顺序排列? 3. return、close、market_cap 的定义是什么? 4. forward return 是否严格晚于 formation date? 5. 缺失值和股票状态如何处理?
导入包并确定项目根目录
notebooks/ 目录启动。from pathlib import Path
import sys
import platform
import pandas as pd
import numpy as np
PROJECT_ROOT = Path.cwd().resolve()
if PROJECT_ROOT.name == "notebooks":
PROJECT_ROOT = PROJECT_ROOT.parent
assert (PROJECT_ROOT / "notebooks").exists(), PROJECT_ROOT
DATA_DIR = PROJECT_ROOT / "data"
PROCESSED_DIR = DATA_DIR / "processed"
TABLE_DIR = PROJECT_ROOT / "reports" / "tables"
FIGURE_DIR = PROJECT_ROOT / "reports" / "figures"
for path in [PROCESSED_DIR, TABLE_DIR, FIGURE_DIR]:
path.mkdir(parents=True, exist_ok=True)
print("Python:", sys.version.split()[0])
print("Pandas:", pd.__version__)
print("Project root:", PROJECT_ROOT)记录数据来源和已知限制
## Data source note Market data source: [填写 Qlib/community/external snapshot] Downloaded at: [填写日期] Price field: [close / adjusted close / total-return series] Return field: [simple return / total return] Market cap field: [字段定义] Industry field: [分类体系] Historical universe: [是否包含退市股票;证据是什么] 本 notebook 的结果仅用于验证研究管线,不能自动证明数据没有 survivorship bias。
读取市场快照并检查 schema
market_path = PROCESSED_DIR / "market_monthly_snapshot.parquet"
market = pd.read_parquet(market_path)
required = {
"instrument", "date", "close", "return", "market_cap"
}
missing = required - set(market.columns)
assert not missing, f"Missing columns: {missing}"
market["date"] = pd.to_datetime(market["date"])
market = market.sort_values(["instrument", "date"])
print("rows:", len(market))
print("instruments:", market["instrument"].nunique())
print("date range:", market["date"].min(), market["date"].max())
display(market.head())检查重复键、排序和基本缺失
duplicate_keys = market.duplicated(
["instrument", "date"]
).sum()
assert duplicate_keys == 0, (
f"duplicate instrument-date rows: {duplicate_keys}"
)
audit = pd.DataFrame({
"dtype": market.dtypes.astype(str),
"missing_rate": market.isna().mean(),
"n_unique": market.nunique(dropna=True),
})
display(audit)
display(market[["close", "return", "market_cap"]].describe())检查价格、收益和市值的基本逻辑
assert (market["market_cap"].dropna() > 0).all()
assert (market["close"].dropna() > 0).all()
bad_return = market[
market["return"].notna()
& ~np.isfinite(market["return"])
]
assert bad_return.empty
market.groupby("instrument")["date"].apply(
lambda s: s.is_monotonic_increasing
).value_counts()构造下一月收益标签
grouped = market.groupby("instrument", group_keys=False)
market["fwd_return_1m"] = grouped["return"].transform(
lambda s: s.shift(-1)
)
display(market[[
"instrument", "date", "return", "fwd_return_1m"
]].head(15))fwd_return_1m(t) = return(t+1)。最后一个月没有下一期标签,应为缺失,而不是填 0。用 toy data 验证标签方向
toy = pd.DataFrame({"return": [0.10, 0.20, 0.30]})
toy["fwd"] = toy["return"].shift(-1)
display(toy)
assert toy.loc[0, "fwd"] == 0.20
assert toy.loc[1, "fwd"] == 0.30
assert pd.isna(toy.loc[2, "fwd"])tests/test_returns.py。输出 Notebook 00 的最小审计表
coverage = (
market.groupby(market["date"].dt.to_period("Y"))
.agg(
instruments=("instrument", "nunique"),
rows=("instrument", "size"),
return_coverage=("return", lambda s: s.notna().mean()),
label_coverage=("fwd_return_1m", lambda s: s.notna().mean()),
)
)
coverage.to_csv(TABLE_DIR / "market_data_audit.csv")
display(coverage)
market.to_parquet(
PROCESSED_DIR / "market_monthly_with_forward_return.parquet",
index=False,
)Checkpoint 00
- 数据 schema、日期范围、股票数、重复键和缺失率已输出。
- 你知道价格、收益和市值字段的定义。
fwd_return_1m只来自下一期。- 没有 SEC、XBRL、B/M 或正式回测代码混进来。
- 你可以用一句话解释当前数据的 survivorship 限制。
Notebook 01:第一个经典因子复现
打开 notebooks/01_momentum_lab.ipynb。这里先只研究一个主因子:MOM_12_1。它的含义是过去 12 个月累计收益,但跳过最近 1 个月。
为什么先做它:
- 经济含义和经典文献背景清楚。
- 只依赖价格/收益,不需要马上处理 SEC 财务字段。
- 可以完整展示 signal → label → IC → portfolio 的研究链条。
- 结果不好时,错误来源比多因子同时运行更容易定位。
Notebook 01:按顺序创建 cell
写 Momentum 研究契约
# 01 — Momentum Lab ## Research question 过去收益是否能预测下一月横截面收益? ## Main signal MOM_12_1 = 过去 12 个月累计收益,跳过最近 1 个月 ## Evaluation - formation date t 使用 t-12 到 t-1 的信息 - label 是 t+1 的收益 - 先计算每月横截面 IC / Rank IC - 再按信号分成五组并计算 Q5-Q1 - 主结果先使用等权,市值加权作为对照 - 不在看到结果后修改主窗口 ## Limited sensitivity after baseline - MOM_3_1 - MOM_6_1 - REV_1M
读取 Notebook 00 的交接文件
market = pd.read_parquet(
PROCESSED_DIR / "market_monthly_with_forward_return.parquet"
)
market["date"] = pd.to_datetime(market["date"])
market = market.sort_values(["instrument", "date"])
assert market.duplicated(
["instrument", "date"]
).sum() == 0
display(market.head())手动实现 MOM_12_1
def trailing_return_skip_one(
series: pd.Series, window: int
) -> pd.Series:
return (
(1 + series)
.shift(1)
.rolling(window, min_periods=window)
.apply(np.prod, raw=True)
- 1
)
grouped = market.groupby("instrument", group_keys=False)
market["MOM_12_1"] = grouped["return"].transform(
lambda s: trailing_return_skip_one(s, 12)
)
display(market[[
"instrument", "date", "MOM_12_1", "fwd_return_1m"
]].dropna().head())shift(1),导致形成日信号包含最近一个月;或使用 shift(-1),直接把未来收益泄漏进信号。用人工序列验证公式
toy = pd.DataFrame({
"return": np.arange(1, 16, dtype=float) / 100
})
toy["mom_3_1"] = (
(1 + toy["return"])
.shift(1)
.rolling(3)
.apply(np.prod)
- 1
)
toy["fwd"] = toy["return"].shift(-1)
display(toy)
# 第 4 行的 mom_3_1 只能使用第 1、2、3 行
expected = (1.02 * 1.03 * 1.04) - 1
assert np.isclose(toy.loc[3, "mom_3_1"], expected)
assert toy.loc[3, "fwd"] == toy.loc[4, "return"]tests/test_momentum.py。按形成月计算横截面 IC
def cross_section_ic(
frame: pd.DataFrame, signal: str
) -> pd.DataFrame:
rows = []
for date, group in frame.groupby("date"):
sample = group[[signal, "fwd_return_1m"]].dropna()
if len(sample) < 30:
continue
rows.append({
"date": date,
"pearson_ic": sample[signal].corr(
sample["fwd_return_1m"]
),
"rank_ic": sample[signal].corr(
sample["fwd_return_1m"], method="spearman"
),
"n": len(sample),
})
return pd.DataFrame(rows).set_index("date")
ic = cross_section_ic(market, "MOM_12_1")
ic["icir"] = ic["rank_ic"].mean() / ic["rank_ic"].std() * np.sqrt(12)
display(ic.describe())
print("positive Rank IC share:", (ic["rank_ic"] > 0).mean())先用简单五分位做 baseline
def assign_quintiles(group: pd.DataFrame,
signal: str) -> pd.DataFrame:
group = group.copy()
valid = group[signal].notna()
group["quintile"] = np.nan
if valid.sum() < 50:
return group
group.loc[valid, "quintile"] = pd.qcut(
group.loc[valid, signal],
q=5,
labels=[1, 2, 3, 4, 5],
duplicates="drop",
).astype(float)
return group
# 先把函数应用到每个 date;保存 date、instrument、signal、quintile、
# fwd_return_1m、market_cap。正式研究再替换为 NYSE breakpoint 函数。pd.qcut 只是当前 baseline 的调试工具。阶段一完成后必须比较 NYSE breakpoints,因为全市场切点会被微型股影响。计算等权和市值加权收益
def equal_weighted_return(group: pd.DataFrame) -> float:
return group["fwd_return_1m"].dropna().mean()
def value_weighted_return(group: pd.DataFrame) -> float:
x = group[["fwd_return_1m", "market_cap"]].dropna()
x = x[x["market_cap"] > 0]
if x.empty:
return np.nan
weights = x["market_cap"] / x["market_cap"].sum()
return (x["fwd_return_1m"] * weights).sum()
# 对每个 date / quintile 分组后调用两个函数,
# 输出 date、quintile、weighting、return、n_stocks。
# 再构造 Q5-Q1 spread。检查五组梯度和 Q5-Q1
summary = (
portfolio_returns
.groupby(["quintile", "weighting"])
.agg(
mean_return=("return", "mean"),
volatility=("return", "std"),
observations=("return", "size"),
average_stocks=("n_stocks", "mean"),
)
.reset_index()
)
summary["annualized_return"] = (
(1 + summary["mean_return"]) ** 12 - 1
)
summary["annualized_vol"] = summary["volatility"] * np.sqrt(12)
summary["sharpe"] = (
summary["annualized_return"] / summary["annualized_vol"]
)
display(summary)写 baseline 结论和限制
## Baseline findings ### Evidence - MOM_12_1 Rank IC mean: ... - Positive Rank IC share: ... - Quintile gradient: ... - Q5-Q1 equal-weight return: ... - Q5-Q1 value-weight return: ... ### Limits - 当前 qcut 不是正式 NYSE breakpoint。 - 当前结果是否包含退市股票,需要回到数据源确认。 - 没有交易成本、借券、容量和行业中性。 - 结果是条件相关,不是因果证明。 ### Next step 完成 baseline 后,才做 MOM_3_1、MOM_6_1、REV_1M 的有限敏感性, 随后进入 02-07 的经典因子 survey。
完成 00/01 后,项目如何继续
完成这两个 Notebook 后,不要直接跳到 SEC。按下面顺序推进:
- 扩展 01 的有限敏感性:加入
MOM_3_1、MOM_6_1、REV_1M,不做大规模网格搜索。 - 完成 02-07:每类 3-4 个代表因子,统一输出公式、数据字段、IC、分组收益和主要暴露。
- 写 08:比较因子相关性、收益相关性、规模/行业/beta 暴露和边际贡献。
- 写 09:从 survey 发现的异常提出一个有经济动机的新因子,并提前写失败标准。
- 再写 10:单独审计 SEC API、CIK/ticker、XBRL tag 和 filed date。
- 写 11-12:构造 SEC Book Equity、6 月市值、B/M 五分位和 French benchmark。
- 写 13:最后统一做行业控制、成本、容量、Newey-West 和 walk-forward OOS。
经典因子地图
00-07:知道每类因子怎么算、预测什么、暴露在哪里。
提出研究问题
08-09:从异常和定义差异中提出可证伪的新假设。
SEC 正式验证
10-13:把 point-in-time B/M 放进严格的资产定价研究。
最终检查清单
Notebook 00
- 控制在 15-25 个 cell 左右,目标是市场数据审计,不是 SEC 项目。
- 有 schema、日期范围、股票数、重复键和缺失率检查。
- 明确 close、return、market_cap 的定义和 survivorship 限制。
fwd_return_1m通过 toy data 验证,严格来自下一期。- 生成固定交接文件,Notebook 01 不复制清洗逻辑。
Notebook 01
- 主因子只有
MOM_12_1,先完成 baseline。 - 通过人工序列验证
shift(1)和 rolling window。 - 计算每期 Pearson IC、Rank IC、有效股票数。
- 完成五分位收益和 Q5-Q1,保存等权/市值加权。
- 明确 qcut 只是早期 baseline,正式版本需要 NYSE breakpoints。
- 结论分为证据、不能证明的事和下一步,不把一条曲线称为 alpha。
真正的完成标准
你可以向别人解释:数据是什么、信号使用了哪些日期、标签来自哪一天、五组如何形成、结果是否单调、结果可能受什么暴露影响,以及下一步为什么要做 Value survey,而不是直接跳到 SEC。