Factor Pricing Research / Linear Build Tutorial

先复现经典因子,再提出自己的研究问题

本教程带你从两个最小 Notebook 开始:先用 00_setup_and_data_audit.ipynb 认识市场数据,再用 01_momentum_lab.ipynb 复现一个经典 Momentum 因子。SEC 原始 B/M 不再放在第一步,而是在经典因子 survey 之后作为正式研究升级。

线性阅读适合边看边写Python + Jupyter先 baseline,后创新
01

先理解新的研究顺序

你指出的问题是正确的:如果第一个 Notebook 就处理 SEC/XBRL、CIK/ticker、公告日期和 Book Equity,那么你首先学到的是数据工程,而不是因子研究。

一个更像 Quant Research 项目的顺序应该是:

理解经典因子 → 复现经典结果 → 比较因子和暴露 → 发现异常/空白 → 提出新假设 → 引入 SEC 原始数据 → 正式验证

因此项目现在拆成三阶段:

阶段Notebook目标
阶段一:经典 survey00 到 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 先作为最后的有限敏感性分析,不让第一天陷入参数组合。
02

开始前:准备环境和最小数据接口

你已经有项目目录:

/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
预期JupyterLab 打开后能看到 notebooks/、configs/、data/、src/。本教程不要求你现在下载 SEC 全市场数据。

如果 Qlib 数据已经准备好,市场快照至少应有下面这些列:

instrument   date   close   return   market_cap   industry   exchange

如果还没有 Qlib 数据,先创建一个小型本地 parquet 快照完成 Notebook 结构验证,并在 data/README.md 中标记“仅用于管线测试”。不要把测试数据的结果写成正式研究结论。

03

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。
03.1

Notebook 00:按顺序创建 cell

Cell 1 · Markdown

写研究契约

为什么先写范围,防止你在看到结果后随意改变股票池、日期和标签。
# 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. 缺失值和股票状态如何处理?
预期别人打开 Notebook,能马上知道它是数据入口,而不是正式策略回测。
Cell 2 · Code

导入包并确定项目根目录

为什么相对路径必须稳定。Notebook 可能从项目根目录或 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)
如果不对先修复启动目录。不要在后续 cell 中到处写绝对路径来掩盖根目录错误。
Cell 3 · Markdown

记录数据来源和已知限制

为什么同一个字段在不同数据源中可能代表不同东西。研究报告必须说明数据口径。
## 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。
Cell 4 · Code

读取市场快照并检查 schema

为什么先验证输入结构,再开始 rolling。没有正确的 panel key,所有后续结果都不可信。
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())
预期看到数据行数、股票数、日期范围和前几行。若文件不存在,先完成数据准备,不要用空 DataFrame 继续。
Cell 5 · Code

检查重复键、排序和基本缺失

为什么重复的 instrument/date 会让一只股票在 rolling 中被计算两次,排序错误会让“过去收益”不再是过去收益。
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())
常见错误把缺失的 return 全部填成 0。停牌、上市前、退市和真实缺失不是同一种情况,先记录原因。
Cell 6 · Code

检查价格、收益和市值的基本逻辑

为什么因子结果异常时,最先应该排查数据字段,而不是马上换模型。
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()
你要确认return 是简单收益还是 total return;close 是否复权;market_cap 是否和 formation date 对齐。把答案写在 Cell 3,不要只靠记忆。
Cell 7 · Code

构造下一月收益标签

为什么因子研究的标签必须严格晚于形成日。这里先不构造复杂信号,只建立后续 Notebook 通用的标签。
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))
在形成日期 t:fwd_return_1m(t) = return(t+1)。最后一个月没有下一期标签,应为缺失,而不是填 0。
Cell 8 · Code

用 toy data 验证标签方向

为什么真实 panel 太复杂,toy data 能直接证明 shift 方向没有写反。
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"])
通过标准第 0 行的标签是第 1 行收益,第 2 行没有标签。这个小测试以后迁移到 tests/test_returns.py。
Cell 9 · Code

输出 Notebook 00 的最小审计表

为什么后续 Notebook 应读取稳定的市场输入和审计结果,而不是重新复制清洗逻辑。
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 限制。
04

Notebook 01:第一个经典因子复现

打开 notebooks/01_momentum_lab.ipynb。这里先只研究一个主因子:MOM_12_1。它的含义是过去 12 个月累计收益,但跳过最近 1 个月。

MOM_12_1(t) = ∏(1 + r[t−12:t−1]) − 1

为什么先做它:

  • 经济含义和经典文献背景清楚。
  • 只依赖价格/收益,不需要马上处理 SEC 财务字段。
  • 可以完整展示 signal → label → IC → portfolio 的研究链条。
  • 结果不好时,错误来源比多因子同时运行更容易定位。
04.1

Notebook 01:按顺序创建 cell

Cell 1 · Markdown

写 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
Cell 2 · Code

读取 Notebook 00 的交接文件

为什么Notebook 01 不重新下载和清洗数据。研究模块之间通过固定文件交接。
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())
如果文件不存在回到 Notebook 00 完成数据审计。不要在 Notebook 01 里临时复制一套读取逻辑。
Cell 3 · Code

手动实现 MOM_12_1

为什么先写可解释公式,之后才有资格比较 Qlib 的对应特征。
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())
预期每只股票至少需要 13 个月左右才会出现第一个有效信号:12 个月历史窗口加上跳过最近月的逻辑。
最常见错误忘记 shift(1),导致形成日信号包含最近一个月;或使用 shift(-1),直接把未来收益泄漏进信号。
Cell 4 · Code

用人工序列验证公式

为什么在真实数据上看到一个数字,不代表你知道它来自哪 12 个月。
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。
Cell 5 · Code

按形成月计算横截面 IC

为什么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())
至少报告IC mean、Rank IC mean、标准差、ICIR、正 IC 月份比例和每期有效股票数。不要只报告一个平均值。
Cell 6 · Code

先用简单五分位做 baseline

为什么第一版要看完整收益梯度。正式版本使用 NYSE breakpoints;如果当前数据没有 exchange 字段,先明确这是调试版,不要冒充正式结果。
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,因为全市场切点会被微型股影响。
Cell 7 · Code

计算等权和市值加权收益

为什么权重不同可能揭示结果到底由小盘股还是大盘股驱动。当前教程主结果先用等权,正式项目再固定市值加权主结果。
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。
组合表必须保留每期股票数、组合权重类型、总市值和收益。只保存累计净值会失去审计能力。
Cell 8 · Code

检查五组梯度和 Q5-Q1

为什么Momentum 证据应该体现在多个月份、多组收益和 spread 中,而不是一张最好看的曲线。
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)
你要问五组是否大致单调?Q5-Q1 是否依赖少数月份?等权和市值加权差多少?如果答案是否定的,这就是结果,不是代码必须被“修好”。
Cell 9 · Markdown

写 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。
05

完成 00/01 后,项目如何继续

完成这两个 Notebook 后,不要直接跳到 SEC。按下面顺序推进:

  1. 扩展 01 的有限敏感性:加入 MOM_3_1、MOM_6_1、REV_1M,不做大规模网格搜索。
  2. 完成 02-07:每类 3-4 个代表因子,统一输出公式、数据字段、IC、分组收益和主要暴露。
  3. 写 08:比较因子相关性、收益相关性、规模/行业/beta 暴露和边际贡献。
  4. 写 09:从 survey 发现的异常提出一个有经济动机的新因子,并提前写失败标准。
  5. 再写 10:单独审计 SEC API、CIK/ticker、XBRL tag 和 filed date。
  6. 写 11-12:构造 SEC Book Equity、6 月市值、B/M 五分位和 French benchmark。
  7. 写 13:最后统一做行业控制、成本、容量、Newey-West 和 walk-forward OOS。
关键改变:SEC B/M 不再是“学习因子研究的第一步”,而是你在已经理解经典因子、发现研究问题之后,用原始数据证明自己研究能力的正式项目。
PHASE 1

经典因子地图

00-07:知道每类因子怎么算、预测什么、暴露在哪里。

PHASE 2

提出研究问题

08-09:从异常和定义差异中提出可证伪的新假设。

PHASE 3

SEC 正式验证

10-13:把 point-in-time B/M 放进严格的资产定价研究。

06

最终检查清单

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。