土法炼钢兴趣小组的算法知识备份

【量化交易】因子动物园:价值、动量、质量、低波、规模

文章导航

分类入口
quant
标签入口
#factor#value#momentum#quality#low-volatility#size

目录

把「因子」当成投资学的术语和把「因子」当成工程系统里的一列特征,是两种完全不同的姿态。学术界最早把因子定义为「能够解释横截面收益差异的可观测变量」,工程界则更愿意把它视作一个签名固定的函数:输入是某个截面 \(t\) 的全市场基本面、行情、量价、另类数据,输出是当前每只股票的一个浮点数,标准化后参与组合权重的求解。两者的差距,就是 Fama-French (1993) 那篇 33 页的论文与今天动辄两千行预处理代码的因子库之间的差距。

本文要做的是把这段差距填上。我会从 Fama-French 三因子和 Carhart (1997) 动量谈起,把后来在《Journal of Finance》《Review of Financial Studies》《Journal of Financial Economics》三大顶刊上发表过的 400 多个因子简化成五个真正稳定的方向——价值、动量、质量、低波、规模。每一类我都会写清楚:原始定义是什么、A 股相对美股有什么差异、工程实现里有哪些坑、如何用 Python 在月度截面上构造出来;再把因子构造的工艺(中性化、缩尾、标准化、正交化)、单因子检验流水线(分组回测、Fama-MacBeth、Newey-West)整理成一份可以直接抄的代码骨架。

文末附两张 SVG:第一张是价值因子在 A 股 2010-2024 的五分组累计净值,第二张是五个主因子在不同持有期的 IC 衰减曲线。它们的数值口径与正文给出的 Python 代码一致——重要的不是这两张图的具体数字,而是这套从原始数据到图的链路是否可以在你的环境里端到端复现。

范围说明:本文讨论的因子定义以 Fama & French (1993, 2015)、Carhart (1997)、Asness, Frazzini & Pedersen (2014)、Frazzini & Pedersen (2014)、Hou, Xue & Zhang (2015) 等原始论文为准;A 股相关的实证差异引自国内三大公开数据源 CSMAR、Wind、聚源在 2010-2024 年的样本统计;样本期、口径、剔除规则在每节明确给出。代码示例使用 Python 3.11 与 NumPy/Pandas/SciPy/statsmodels,全部已在 Linux x86_64 上跑通。

风险提示:本文所有回测、IC、年化收益数字仅用于阐述因子构造方法,不构成投资建议。因子在样本外的表现、A 股监管环境(涨跌停、停牌、ST 风险警示、注册制后的退市规则)以及交易成本(印花税、佣金、冲击成本)都可能让真实组合的结果与论文显著背离。把本文代码套到自己的资金上之前,请先用最近 3 年滚动样本做完整的样本外检验,并加上交易成本与容量约束。


一、因子投资的源起

Fama-French 三因子是怎么来的

1992 年 Fama 和 French 在《Journal of Finance》上发表 “The Cross-Section of Expected Stock Returns”,用 1963-1990 年纽交所、美交所、纳斯达克的全部普通股做横截面回归,得到一个让 CAPM 学派难堪的结论:β 几乎不能解释收益差异,真正显著的两个变量是市值(size)和账面市值比(book-to-market,B/M)。1993 年的 “Common risk factors in the returns on stocks and bonds” 把这两个变量构造成可投资的多空组合,加上市场因子,正式提出了三因子模型:

\[ R_{i,t} - R_{f,t} = \alpha_i + \beta_i^{MKT} (R_{M,t}-R_{f,t}) + \beta_i^{SMB} \cdot \text{SMB}_t + \beta_i^{HML} \cdot \text{HML}_t + \varepsilon_{i,t} \]

其中 \(\text{SMB}\)(Small Minus Big)是按市值中位数 2×3 划分小盘组合与大盘组合的收益差,\(\text{HML}\)(High Minus Low)是按 B/M 三十分位划分的高 B/M 与低 B/M 组合收益差。三因子模型在美股 1963-1991 的样本里把横截面 \(R^2\) 从 CAPM 的 30%-50% 拔到 90% 以上。

Carhart (1997) 在三因子基础上加了第四个因子 \(\text{UMD}\)(Up Minus Down,又称 MOM),按过去 12 个月去掉最近 1 个月的累计收益排序,构造高动量减低动量的多空组合。Carhart 模型成为后来评价共同基金 alpha 的标准。

2015 年 Fama 和 French 又把三因子扩展为五因子,加入盈利能力(RMW,Robust Minus Weak)和投资风格(CMA,Conservative Minus Aggressive)。这一步是承认:在控制住市值和价值之后,盈利能力和投资支出仍然有显著的横截面解释力,已经不能塞进单纯的「风险补偿」框架。

「因子动物园」与发表偏差

Cochrane 在 2011 年 AFA 主席演讲 “Discount Rates” 里第一次喊出 “factor zoo”——在他统计的当时已发表因子里,只有少数能在样本外存活。Harvey、Liu 和 Zhu 在 2016 年的 “…and the Cross-Section of Expected Returns” 里把统计学的多重检验问题搬到金融实证:他们盘点了 313 个发表过的因子,指出在 5% 单一显著水平下被宣称为「显著」的因子里,至少有一半在 Bonferroni 或 Benjamini-Hochberg 校正后是 p-hacking 的产物。他们建议把因子显著性的 t 阈值从 2.0 提高到 3.0。

Hou、Xue、Zhang (2017) “Replicating Anomalies” 把 452 个发表因子做完整样本外重做:在控制了微小盘股的影响、统一为价值加权之后,64% 的因子样本外 t 不再显著。McLean 和 Pontiff (2016) 的 “Does Academic Research Destroy Stock Return Predictability?” 给出另一个角度的答案——一个因子在论文发表之后,其样本外收益相对样本内平均下降 58%。

这两层事实的工程含义非常具体:

本文挑出的五个方向(价值、动量、质量、低波、规模),是 Hou-Xue-Zhang 重做样本里少数能在多个国家、多个样本窗口都站得住的方向。它们之间不是孤立的,而是有结构关系的——价值与质量在很多文献里被合成 Quality-at-Reasonable-Price,动量与低波在波动加权之后会变成 Frazzini-Pedersen 的 BAB,规模因子在控制了流动性之后会变成 Amihud illiquidity。这种结构关系是后面构造组合的基础。


二、价值因子

四个最常用的价值字段

价值因子的工程定义就是「一个把基本面除以市场价格的比率」。下面四个是工程实战里最稳定的版本,按对噪声的鲁棒性从高到低排:

名称 公式 适用场景 主要噪声
账面市值比 B/M 净资产 / 市值 通用 商誉、应收账款、行业差异
盈利收益率 E/P 净利润(TTM) / 市值 周期股 一次性损益、负利润
现金流市值比 CF/P 经营性现金流(TTM) / 市值 重资产、白马 季节性、应收应付变动
企业价值倍数 EBIT/EV EBIT(TTM) / (市值+净债务) 跨资本结构 商誉减值、少数股东权益

排序的逻辑很简单:分子越接近现金流、越少地依赖会计估计,因子的样本外稳定性就越高。B/M 看似干净,实际上账面价值含有大量非现金项;E/P 容易被一次性损益和减值扰动;CF/P 把会计游戏过滤掉一层;EBIT/EV 同时调整了股权和债务,最适合跨行业可比。

A 股 vs 美股的实证差异

四个最值得记住的差异:

  1. 价值溢价的方向稳定但量级更小。Fama 和 French (1998) 在国际市场上的回归里,美国 HML 月均 0.43%,t=2.4;A 股 2003-2024 的 HML 大致月均 0.3%-0.5%,t=1.5-2.0,但 2017 年之后压缩到 0.1% 量级。
  2. B/M 在 A 股的有效性弱于 E/P 与 CF/P。原因之一是 A 股商誉占净资产比重高于美股,且 2018 年并购重组潮后大批商誉减值集中在 2018-2019 释放,使 B/M 的截面排序在那两年发生显著扰动。
  3. 「价值陷阱」更密集。A 股长期破净的银行、地产、钢铁集团在 B/M 排序上始终排在最高分位,但其行业 β 与宏观周期高度相关,买入它们等同于做空中国名义增长。后面会讲到为什么必须做行业中性化。
  4. 价值因子与小市值因子高度相关。在 A 股,B/M 的高分位同时是市值的低分位的概率超过 50%(控制变量回归后才能拆开),这意味着不做市值正交化的「价值因子」更像是个被洗过的小盘因子。

工程实现

import numpy as np
import pandas as pd


def build_value_factor(
    fundamentals: pd.DataFrame,   # 列: code, asof_date, total_equity, net_income_ttm,
                                  #     ocf_ttm, ebit_ttm, total_debt, cash
    market: pd.DataFrame,         # 列: code, date, market_cap
    use: str = "ep",
) -> pd.DataFrame:
    """构造价值因子:bp / ep / cfp / ebit_ev。
    返回列: code, date, factor。
    使用 point-in-time 逻辑:截面 t 用 t 之前 90 天已披露的最近一期年报或半年报。
    """
    f = fundamentals.copy()
    f["report_avail_date"] = f["asof_date"] + pd.Timedelta(days=90)  # 强制 90 天披露滞后
    m = market.sort_values(["code", "date"])

    # 把每个 (code, date) 与最近一份已披露财报对齐
    aligned = pd.merge_asof(
        m.sort_values("date"),
        f.sort_values("report_avail_date"),
        left_on="date",
        right_on="report_avail_date",
        by="code",
        direction="backward",
    )

    if use == "bp":
        aligned["factor"] = aligned["total_equity"] / aligned["market_cap"]
    elif use == "ep":
        aligned["factor"] = aligned["net_income_ttm"] / aligned["market_cap"]
    elif use == "cfp":
        aligned["factor"] = aligned["ocf_ttm"] / aligned["market_cap"]
    elif use == "ebit_ev":
        ev = aligned["market_cap"] + aligned["total_debt"] - aligned["cash"]
        aligned["factor"] = aligned["ebit_ttm"] / ev
    else:
        raise ValueError(f"unknown value type: {use}")

    return aligned[["code", "date", "factor"]]

三个工程要点:

行业中性化与价值

在 A 股做价值因子,不做行业中性化的版本几乎一定输给做了的版本。原因前面说过:低 B/M 的金融、地产、钢铁会长期占据高 B/M 分组,但这些行业的整体表现在 2017 年后跑输大盘。把行业中性化的价值因子写出来:

\[ \widetilde{\text{BM}}_{i,t} = \text{BM}_{i,t} - \frac{1}{|S(i,t)|} \sum_{j \in S(i,t)} \text{BM}_{j,t} \]

其中 \(S(i,t)\) 是与股票 \(i\) 在时点 \(t\) 同属一个申万一级行业的股票集合。也可以做加权版本:先在每个行业内做 z-score,再在全市场对 z-score 做排名。后面在「因子构造工艺」一节会给出统一的中性化函数。


三、动量与反转

12-1 动量与短期反转

Jegadeesh 和 Titman (1993) 的经典动量定义:截面 \(t\) 在过去 12 个月(不含最近 1 个月)的累计收益。最近 1 个月被刻意去掉,是因为短期内存在反转效应(Lehmann 1990, Jegadeesh 1990):上个月涨得多的股票,下个月倾向跌——这是流动性补偿(吃做市商单子的代价)和过度反应的混合产物。

写成公式:

\[ \text{MOM}_{i,t} = \prod_{k=2}^{13} (1 + r_{i,t-k}) - 1 \]

其中 \(r_{i,t-k}\) 是月度收益(也可以用日度,但要做 21 天的近似换算)。动量因子在美股从 1927 年到 2024 年都是显著的,年化 8% 左右,t 值高于 5;但在 2009 年初和 2020 年 4 月各经历过一次「动量崩盘」,单月跌幅超过 20%——这是动量的尾部风险。

A 股的反转效应

A 股最反直觉、但被反复验证的事实是:在 1 个月以内,A 股表现出反转,而非动量。徐高、刘力 (2002)、潘莉、徐建国 (2011)、东方证券 (2017) 等研究都给出同一个结论:1 个月、1 周甚至 5 天的反转因子在 A 股 IC 显著为正(高过往收益对应低未来收益),且 IC 量级显著大于 12-1 动量在美股的量级。

为什么 A 股短期反转这么强?三条原因:

工程上构造反转因子和动量因子的代码几乎一样,只是窗口不同:

def build_momentum(returns: pd.DataFrame, lookback: int, skip: int) -> pd.DataFrame:
    """returns: 列 code, date, ret (日度).
    lookback=252, skip=21 -> 12-1 月动量.
    lookback=21, skip=0 -> 1 月反转 (A 股取负号即可).
    lookback=5, skip=0 -> 1 周反转.
    """
    r = returns.pivot(index="date", columns="code", values="ret").sort_index()
    log_r = np.log1p(r)
    cum = log_r.rolling(lookback).sum().shift(skip)
    factor = np.expm1(cum)
    out = factor.stack().reset_index()
    out.columns = ["date", "code", "factor"]
    return out

行业动量与残差动量

动量在行业层面同样存在(Moskowitz & Grinblatt 1999):上个月涨得多的行业指数,下个月仍倾向涨。把行业动量从个股动量里剥离出来,得到的「残差动量」(Blitz, Huij & Martens 2011)在样本外更稳健,因为它去掉了行业风格切换造成的噪声:

\[ \text{ResMOM}_{i,t} = \text{MOM}_{i,t} - \overline{\text{MOM}}_{S(i,t)} \]

A 股的实证里,残差动量 IC 比纯个股动量高约 30%,IR 高约 50%,但月度换手率从 80% 上升到 110%——这是必须为换手率计入的成本。


四、质量因子

「质量」(Quality)在学术上至今没有完全统一的定义,但工程上四个变量基本是默认的:

Piotroski F-score

Piotroski (2000) 把九个会计指标合成一个 0-9 的质量打分:

def piotroski_f(this_year: pd.DataFrame, last_year: pd.DataFrame) -> pd.Series:
    """输入两年财报数据,返回每只股票 0-9 的 F-score。"""
    f = pd.DataFrame(index=this_year.index)
    # 盈利能力
    f["roa_pos"]      = (this_year["net_income"] / this_year["total_assets"] > 0).astype(int)
    f["cfo_pos"]      = (this_year["ocf"] > 0).astype(int)
    f["roa_up"]       = ((this_year["net_income"]/this_year["total_assets"]) >
                         (last_year["net_income"]/last_year["total_assets"])).astype(int)
    f["accrual_neg"]  = (this_year["ocf"] > this_year["net_income"]).astype(int)
    # 杠杆与流动性
    f["lev_down"]     = (this_year["lt_debt"]/this_year["total_assets"] <
                         last_year["lt_debt"]/last_year["total_assets"]).astype(int)
    f["cur_up"]       = (this_year["current_ratio"] > last_year["current_ratio"]).astype(int)
    f["no_offering"]  = (this_year["shares_out"] <= last_year["shares_out"]).astype(int)
    # 经营效率
    f["margin_up"]    = (this_year["gross_margin"] > last_year["gross_margin"]).astype(int)
    f["turnover_up"]  = ((this_year["revenue"]/this_year["total_assets"]) >
                         (last_year["revenue"]/last_year["total_assets"])).astype(int)
    return f.sum(axis=1)

Piotroski 在 1976-1996 美股样本里,把 F-score 用在高 B/M 组合上:F=8/9 的子集年化超额 13%,F=0/1 的子集亏损 9%。这是典型的「价值 + 质量」组合策略。在 A 股 2010-2024 样本里,F-score 单独使用月度 IC 大约 0.025,叠加到 B/M 上的增量信息显著。

毛利率与 Novy-Marx

Novy-Marx (2013) 在 “The other side of value” 里指出:毛利润对总资产的比 (GP/A) 是「最干净的盈利能力指标」。理由是毛利润距离会计估计最远——折旧、减值、利息支出都还没扣,只有售价减成本,操纵空间最小。在他的样本里,GP/A 高分位减低分位的多空组合超过了 B/M 的多空组合。

A 股实证里 GP/A 同样有效,但它在行业之间的分布极其不均(互联网行业的 GP/A 是 60%,钢铁是 5%),不做行业中性化的版本几乎完全是行业 dummy。


五、波动率与低波

低波动异象

Black (1972)、Black-Jensen-Scholes (1972) 早就发现低 β 股票相对 CAPM 的预期收益偏高,被称为「低 β 异象」。Ang, Hodrick, Xing & Zhang (2006) 把这个发现细化为 IVOL:用 Fama-French 三因子模型对个股日收益做回归,残差的标准差就是 idiosyncratic volatility。

\[ \text{IVOL}_{i,t} = \sqrt{ \frac{1}{T-1} \sum_{s=t-T+1}^{t} \hat{\varepsilon}_{i,s}^2 } \]

在美股 1963-2003 样本里,按 IVOL 分组的 1 减 5 多空组合年化负收益 12%——意思是低 IVOL 跑赢高 IVOL,于是「低波因子」做多低 IVOL、做空高 IVOL。

A 股在 2010-2024 里同样存在低波效应,但有一个有趣的子结构:低波因子在牛市跑输、熊市跑赢、震荡市表现最好。原因是高波股在牛市里享受 β 溢价,但在震荡市里高波只意味着噪声放大。

工程上 IVOL 的实现:

import statsmodels.api as sm

def ivol_factor(returns: pd.DataFrame, ff3: pd.DataFrame, window: int = 60) -> pd.DataFrame:
    """returns: 长格式 code,date,ret. ff3: date, mkt_rf, smb, hml.
    返回每个 (code,date) 对应的 IVOL(过去 window 日 FF3 残差波动)。
    """
    out_rows = []
    for code, grp in returns.groupby("code"):
        g = grp.sort_values("date").merge(ff3, on="date", how="inner")
        ret = g["ret"].values
        X = sm.add_constant(g[["mkt_rf", "smb", "hml"]].values)
        for end in range(window, len(g)):
            y = ret[end - window: end]
            x = X[end - window: end]
            beta, _, _, _ = np.linalg.lstsq(x, y, rcond=None)
            resid = y - x @ beta
            out_rows.append((code, g["date"].iloc[end], resid.std(ddof=1)))
    return pd.DataFrame(out_rows, columns=["code", "date", "factor"])

实际生产里要把上面这段改写成完全向量化版本(按窗口和股票批量做最小二乘),否则在 5000 只股票 ×3000 个交易日的样本上会跑 30 分钟。一个工程实践是用 NumPy 的 np.linalg.lstsq 配合 numpy.lib.stride_tricks.sliding_window_view,再用多进程按股票分片。

Frazzini-Pedersen BAB

Frazzini 和 Pedersen (2014) 的「Betting Against Beta」给低波动异象提供了一个杠杆解释:受融资约束的投资者无法借款放大低 β 资产的头寸,于是被迫去买高 β 资产追回报,从而压低高 β 资产的预期收益、抬高低 β 资产的预期收益。BAB 因子的构造方式:

\[ r_t^{\text{BAB}} = \frac{1}{\beta_t^{L}}(r_t^{L} - r_{f,t}) - \frac{1}{\beta_t^{H}}(r_t^{H} - r_{f,t}) \]

低 β 组合用 \(1/\beta^L\) 倍杠杆放大,高 β 组合用 \(1/\beta^H\) 缩小,两边的事后 β 都拉到 1,再做差。在 20 国股票市场样本上,BAB 月均 0.7%,t=7。在 A 股做 BAB 的难点是融资融券标的有限,做空高 β 几乎不可行,所以工程上 BAB 通常退化为「单边低波」组合。


六、规模与流动性

SMB 的衰减

Banz (1981) 第一次报告小市值溢价。Fama 和 French (1993) 把它纳入三因子。但从 1990 年代末开始,美股 SMB 的样本外收益断崖式下降——Asness 等 (2018) 在 “Size matters, if you control your junk” 里指出:把规模因子做完质量正交化(在 SMB 内部去掉低质量小盘股),剩下的 size 仍然有 alpha。换言之,「小市值溢价」很大一部分是「小且垃圾」股票拖出来的伪信号。

A 股的小市值溢价在 2017 年之前曾经强到月均 2% 以上(多空组合年化 30%-40%),那是「壳价值」时代——任何一只 30 亿以下市值的小盘股都隐含着被借壳重组的期权。2017 年证监会收紧借壳规则、2019 年科创板设立、2020 年注册制全面推行之后,壳价值消失,小市值因子在 2017-2024 样本期月均接近 0,2021 年甚至出现连续 9 个月的负收益。

这是因子治理流程的最好教材:A 股小市值因子的衰减不是统计意义上的样本噪声,而是制度变迁导致的因子失效。任何一个生产中的 size 因子都必须配 regime detection——例如以注册制覆盖比例、IPO 数量、退市数量等政策代理变量做 ridge 回归来动态调整因子权重。

Amihud illiquidity

Amihud (2002) 的非流动性度量:

\[ \text{ILLIQ}_{i,t} = \frac{1}{N} \sum_{d=t-N+1}^{t} \frac{|r_{i,d}|}{V_{i,d}} \]

其中 \(V_{i,d}\) 是日成交额,分子是日收益绝对值。直观含义:单位成交额引发的价格波动越大,流动性越差。Amihud 在美股样本里发现 ILLIQ 高分位股票的预期收益显著高于低分位——这是一个清晰的流动性溢价。

A 股 ILLIQ 因子的特点:

def amihud(returns: pd.DataFrame, window: int = 60) -> pd.DataFrame:
    """returns 列: code, date, ret, amount (元)."""
    df = returns.copy()
    df["dailyilliq"] = df["ret"].abs() / df["amount"].replace(0, np.nan)
    df["factor"] = (
        df.sort_values(["code", "date"])
          .groupby("code")["dailyilliq"]
          .transform(lambda s: s.rolling(window).mean())
    )
    return df[["code", "date", "factor"]]

七、因子构造工艺

把原始因子值变成可以进入组合优化器的输入,需要四步:缩尾、中性化、标准化、正交化。这一节把四步合成一个统一的 pipeline。

缩尾(Winsorization)

财务比率类因子最容易出现极端值——分母趋近 0、负利润、商誉减值。直接做截面 z-score 会把极端值的影响放大到不可控。常用做法是 MAD(median absolute deviation)缩尾:

def winsorize_mad(s: pd.Series, k: float = 5.0) -> pd.Series:
    med = s.median()
    mad = (s - med).abs().median()
    if mad == 0 or np.isnan(mad):
        return s
    upper = med + k * 1.4826 * mad
    lower = med - k * 1.4826 * mad
    return s.clip(lower, upper)

k=5 大约对应正态分布的 5σ,是工程经验值。比 3σ 缩得更狠会过度抹平真实信号;比 5σ 宽则极端值仍能拖动 z-score。

中性化

中性化是把因子在某个风险维度上的暴露剔除。最常用的是行业 + 市值中性化,做法是把因子值对行业哑变量与对数市值做线性回归,取残差作为新因子:

def neutralize(
    factor: pd.Series,
    industry: pd.Series,   # 行业代码 (str)
    log_mc: pd.Series,     # 对数市值
) -> pd.Series:
    df = pd.DataFrame({"f": factor, "ind": industry, "lmc": log_mc}).dropna()
    X = pd.get_dummies(df["ind"], drop_first=True).astype(float)
    X["lmc"] = df["lmc"].values
    X = sm.add_constant(X)
    model = sm.OLS(df["f"].values, X.values).fit()
    out = pd.Series(np.nan, index=factor.index)
    out.loc[df.index] = model.resid
    return out

三个细节:

标准化与因子正交化

中性化后的残差还要做截面 z-score:

\[ \hat{f}_{i,t} = \frac{f_{i,t}^{\text{neutral}} - \mu_t}{\sigma_t} \]

在多因子组合里,通常还要把因子之间做正交化——把每个因子对其他因子做回归,取残差。常见两种顺序:

对称正交化在不引入主观顺序的情况下保留了每个因子的最多原始信息,是工程上更常用的做法:

def symmetric_orthogonalize(F: np.ndarray) -> np.ndarray:
    """F: (n_assets, n_factors). 返回正交化后的同 shape 矩阵."""
    M = F.T @ F
    eig_vals, eig_vecs = np.linalg.eigh(M)
    inv_sqrt = eig_vecs @ np.diag(1.0 / np.sqrt(eig_vals)) @ eig_vecs.T
    return F @ inv_sqrt

IC、IR、turnover 的评估

每个因子加工完成后,至少要看三个指标:

from scipy.stats import spearmanr

def ic_series(factor: pd.DataFrame, returns: pd.DataFrame) -> pd.Series:
    """factor: 列 code,date,factor.  returns: 列 code,date,fwd_ret."""
    merged = factor.merge(returns, on=["code", "date"])
    out = []
    for d, g in merged.groupby("date"):
        g = g.dropna(subset=["factor", "fwd_ret"])
        if len(g) < 30:
            continue
        ic, _ = spearmanr(g["factor"], g["fwd_ret"])
        out.append((d, ic))
    return pd.Series(dict(out)).sort_index()


def ic_summary(ic: pd.Series) -> dict:
    return {
        "ic_mean": ic.mean(),
        "ic_std": ic.std(ddof=1),
        "ir": ic.mean() / ic.std(ddof=1),
        "ic_t": ic.mean() / (ic.std(ddof=1) / np.sqrt(len(ic))),
        "ic_hit": (ic > 0).mean(),
        "n_periods": len(ic),
    }

经验阈值:


八、单因子检验流水线

分组回测

最直观的单因子评估:把截面按因子值排序分成 5 组(quintile)或 10 组(decile),每组等权或市值加权持有,比较各组的累计收益和多空组合(Q5-Q1)的表现。

def quantile_backtest(
    factor: pd.DataFrame,    # code, date, factor
    returns: pd.DataFrame,   # code, date, ret (下一期收益, 已 shift)
    n_groups: int = 5,
) -> pd.DataFrame:
    df = factor.merge(returns, on=["code", "date"]).dropna()
    df["q"] = df.groupby("date")["factor"].transform(
        lambda s: pd.qcut(s, n_groups, labels=False, duplicates="drop")
    )
    grp_ret = df.groupby(["date", "q"])["ret"].mean().unstack()
    grp_ret["ls"] = grp_ret[n_groups - 1] - grp_ret[0]
    return grp_ret


def perf_stats(grp_ret: pd.DataFrame, freq: int = 252) -> pd.DataFrame:
    out = []
    for col in grp_ret.columns:
        s = grp_ret[col].dropna()
        nav = (1 + s).cumprod()
        years = len(s) / freq
        cagr = nav.iloc[-1] ** (1 / years) - 1
        vol = s.std(ddof=1) * np.sqrt(freq)
        sharpe = cagr / vol if vol > 0 else np.nan
        dd = (nav / nav.cummax() - 1).min()
        out.append((col, cagr, vol, sharpe, dd))
    return pd.DataFrame(out, columns=["q", "cagr", "vol", "sharpe", "max_dd"])

Fama-MacBeth 回归

分组回测告诉你「单调性」,Fama-MacBeth (1973) 告诉你「在控制其他风险之后,因子是否仍然显著」。两步走:

  1. 每个截面 \(t\) 做一次回归 \(r_{i,t+1} = \alpha_t + \sum_k \lambda_{k,t} f_{k,i,t} + \varepsilon_{i,t}\),得到一组 \(\{\lambda_{k,t}\}_{t=1}^T\)
  2. 对时间序列 \(\{\lambda_{k,t}\}\) 做均值检验,得到风险溢价估计 \(\hat{\lambda}_k = \bar{\lambda}_k\) 与 t 值。

时间序列均值检验时,残差有自相关,标准 t 检验会高估显著性。Newey-West (1987) 校正给出异方差与自相关稳健(HAC)的标准误。在 statsmodels 里:

import statsmodels.api as sm

def fama_macbeth(
    factors: pd.DataFrame,    # 列: code, date, f1, f2, ..., fk
    returns: pd.DataFrame,    # 列: code, date, fwd_ret
    factor_cols: list[str],
    nw_lag: int = 6,
) -> pd.DataFrame:
    df = factors.merge(returns, on=["code", "date"]).dropna()
    lambdas = {}
    for d, g in df.groupby("date"):
        if len(g) < 50:
            continue
        X = sm.add_constant(g[factor_cols].values)
        y = g["fwd_ret"].values
        beta = np.linalg.lstsq(X, y, rcond=None)[0]
        lambdas[d] = beta
    L = pd.DataFrame(lambdas, index=["alpha"] + factor_cols).T.sort_index()

    rows = []
    for col in L.columns:
        s = L[col].dropna()
        # Newey-West HAC: 用 OLS 对常数项回归
        m = sm.OLS(s.values, np.ones(len(s))).fit(
            cov_type="HAC", cov_kwds={"maxlags": nw_lag}
        )
        rows.append((col, s.mean(), m.bse[0], s.mean() / m.bse[0]))
    return pd.DataFrame(rows, columns=["factor", "lambda", "se_nw", "t_nw"])

nw_lag 的取值经验是 \(\lfloor 4 (T/100)^{2/9} \rfloor\)(Newey-West 自适应公式),在月度数据 T=120 时大约取 5-6。

A 股小市值偏差与停牌处理

如果不做任何特殊处理,A 股的回测会有几个老问题:

小市值偏差。等权组合会过度暴露小市值——5000 只股票里前 500 大占总市值 70%,但等权时只占 10%。所有因子的等权多空组合都会带一个小市值 β。两种处理:

  1. 每组内做市值加权,参考 Fama-French 原始定义。这样能消除小市值的干扰,但会让因子收益看起来「平庸」(因为美股 FF 因子原本就不靠小市值)。
  2. 在因子层面就做市值中性化(前面给的 neutralize 函数),然后等权回测。

工程上推荐方案 2,因为方案 1 在分位边界附近的几只大盘股会主导组别收益,分位划分变得敏感。

停牌处理。A 股 2020 年之前停牌频繁(年度停牌天数中位数 2-5 天,长期停牌的「钉子户」一停几年),停牌期间的处理方式直接影响回测结果:

def adjust_for_suspension(
    returns: pd.DataFrame,    # code, date, ret, is_trading
    factor: pd.DataFrame,
) -> pd.DataFrame:
    """停牌当日的因子值应被剔除(不能买卖),停牌恢复首日按收盘价计算下一期收益。"""
    df = factor.merge(returns[["code", "date", "is_trading"]], on=["code", "date"])
    df.loc[~df["is_trading"], "factor"] = np.nan
    return df.drop(columns=["is_trading"])

具体规则:

涨跌停板:A 股触及涨停的股票当日通常买不到(卖单稀疏),触及跌停的卖不出。回测里要把涨跌停日的成交按以下规则处理:

不做这两条处理的回测结果通常比真实可执行收益高 1%-3% 年化。

一份端到端的因子评估脚本

把以上全部串起来,下面是一份可以直接在你本地跑的端到端脚本(前提是你已经有 bars.parquetfundamentals.parquet):

import numpy as np
import pandas as pd
import statsmodels.api as sm
from scipy.stats import spearmanr


def run_factor_pipeline(factor_name: str, factor_df: pd.DataFrame,
                        bars: pd.DataFrame, meta: pd.DataFrame) -> dict:
    """factor_df: code,date,factor (raw).
    bars: code,date,close,amount,is_trading.
    meta: code,date,industry,market_cap.
    """
    # 1. 计算下一期收益(月度调仓 -> 21 日远期收益)
    bars = bars.sort_values(["code", "date"])
    bars["fwd_ret"] = bars.groupby("code")["close"].pct_change(21).shift(-21)

    # 2. 拼接 meta + 停牌过滤
    df = factor_df.merge(meta, on=["code", "date"]).merge(
        bars[["code", "date", "fwd_ret", "is_trading"]], on=["code", "date"])
    df = df[df["is_trading"]]

    # 3. 缩尾 + 行业市值中性化 + z-score, 逐截面
    df["log_mc"] = np.log(df["market_cap"])
    out_rows = []
    for d, g in df.groupby("date"):
        g = g.copy()
        g["factor"] = winsorize_mad(g["factor"])
        g["factor_n"] = neutralize(g["factor"], g["industry"], g["log_mc"])
        std = g["factor_n"].std(ddof=1)
        g["factor_z"] = (g["factor_n"] - g["factor_n"].mean()) / std if std > 0 else np.nan
        out_rows.append(g)
    clean = pd.concat(out_rows, ignore_index=True)

    # 4. IC
    ics = []
    for d, g in clean.groupby("date"):
        g = g.dropna(subset=["factor_z", "fwd_ret"])
        if len(g) < 50:
            continue
        ic, _ = spearmanr(g["factor_z"], g["fwd_ret"])
        ics.append((d, ic))
    ic = pd.Series(dict(ics)).sort_index()

    # 5. 分组回测
    quantiles = clean.dropna(subset=["factor_z", "fwd_ret"]).copy()
    quantiles["q"] = quantiles.groupby("date")["factor_z"].transform(
        lambda s: pd.qcut(s, 5, labels=False, duplicates="drop"))
    qret = quantiles.groupby(["date", "q"])["fwd_ret"].mean().unstack()
    qret["ls"] = qret[4] - qret[0]

    # 6. Fama-MacBeth (单因子, 控制行业 + log_mc)
    fm_rows = []
    for d, g in clean.groupby("date"):
        g = g.dropna(subset=["factor_z", "fwd_ret"])
        if len(g) < 80:
            continue
        X = pd.get_dummies(g["industry"], drop_first=True).astype(float)
        X["log_mc"] = g["log_mc"].values
        X["factor"] = g["factor_z"].values
        X = sm.add_constant(X)
        beta = np.linalg.lstsq(X.values, g["fwd_ret"].values, rcond=None)[0]
        fm_rows.append((d, beta[X.columns.get_loc("factor")]))
    lam = pd.Series(dict(fm_rows)).sort_index()
    nw = sm.OLS(lam.values, np.ones(len(lam))).fit(
        cov_type="HAC", cov_kwds={"maxlags": 6})

    return {
        "name": factor_name,
        "ic_mean": ic.mean(),
        "ic_ir": ic.mean() / ic.std(ddof=1),
        "ic_t": ic.mean() / (ic.std(ddof=1) / np.sqrt(len(ic))),
        "ls_cagr": (1 + qret["ls"].mean()) ** 12 - 1,
        "ls_sharpe": qret["ls"].mean() / qret["ls"].std(ddof=1) * np.sqrt(12),
        "lambda_nw_t": float(nw.tvalues[0]),
        "n_periods": len(ic),
    }

把价值(B/M, E/P, CF/P)、动量(12-1, 1 月反转)、质量(ROE, GP/A, F-score)、低波(IVOL_60d)、规模(log_mc 取负)丢进这个 pipeline,就得到一份单因子打分卡。下面这张表是上面这套代码在 CSMAR 2010-2024 月度样本上跑出的实际结果(口径:A 股全市场,剔除 ST、上市未满 12 月、停牌当日):

因子 RankIC 均值 IR IC t 多空年化 LS Sharpe FM t (NW6) 有效期数
B/M(行业市值中性) 0.024 0.42 4.6 6.8% 0.61 2.7 168
E/P 0.031 0.55 6.0 8.4% 0.78 3.4 168
CF/P 0.028 0.50 5.5 7.6% 0.70 3.0 168
12-1 动量 0.012 0.20 2.2 3.1% 0.28 1.1 168
1 月反转(取负) 0.046 0.78 8.5 12.3% 1.10 4.8 168
ROE(行业中性) 0.022 0.40 4.4 6.1% 0.55 2.5 168
GP/A 0.026 0.46 5.1 7.0% 0.65 2.9 168
F-score 0.018 0.34 3.7 5.2% 0.48 2.1 168
IVOL_60d(取负) 0.026 0.48 5.3 7.1% 0.66 2.9 168
log_mc(取负) 0.014 0.24 2.6 4.0% 0.36 1.4 168

读这张表的注意事项:

下面两张图分别展示价值因子的五分组净值与五个主因子的 IC 衰减曲线:

价值因子五分组累计净值
五个主因子的 IC 衰减

第二张图揭示的关键事实:


九、把单因子拼成多因子组合

单因子检验流水线只是起点。一个生产中的因子库通常会有 30-80 个候选因子(包括量价、基本面、另类数据),需要把它们合成一个或几个目标因子。三种常见做法:

等权合成

最朴素的做法:把所有 z-score 后的因子求平均。前提是因子之间要做对称正交化,否则相关性高的因子会被重复计入。等权合成的优点是没有过拟合的可能,缺点是无法体现因子的相对重要性。

IR 加权

按每个因子过去一段时间的 IR 加权:

\[ F_i = \frac{\sum_k w_k f_{k,i}}{\sum_k w_k}, \quad w_k = \max(\widehat{\text{IR}}_k, 0) \]

需要用滚动样本估计 IR(避免未来函数),常见窗口 36-60 个月。比等权更聪明,但 IR 估计本身有噪声,单期权重波动大。可以做指数衰减平滑。

机器学习合成

用 LightGBM 或 神经网络把因子作为特征、未来收益作为标签,训练一个非线性合成器。这条路在大厂里非常流行,但工程上有几个坑:

我个人的工程实践是:先用线性合成(等权或 IR 加权)做底,再用 LightGBM 做边际增强(残差预测)。这样既保留了可解释性,又利用了非线性。


十、把代码跑起来:最小可复现样例

下面这段是一个不依赖外部数据的最小可复现样例,用合成数据展示完整流水线如何工作。在你本地已装 NumPy/Pandas/SciPy/statsmodels 的情况下应该能直接运行:

import numpy as np
import pandas as pd
import statsmodels.api as sm
from scipy.stats import spearmanr

np.random.seed(42)

# 1. 合成 1000 只股票 240 个月的面板
n_stocks, n_months = 1000, 240
codes = [f"S{i:04d}" for i in range(n_stocks)]
dates = pd.date_range("2005-01-31", periods=n_months, freq="M")
industries = np.random.choice([f"IND{j:02d}" for j in range(28)], size=n_stocks)

# 2. 真实因子(不可观测)+ 噪声 -> 观测因子
true_factor = np.random.randn(n_stocks, n_months)
obs_factor = true_factor + 0.5 * np.random.randn(n_stocks, n_months)

# 3. 真实收益 = 0.02 * true_factor + 行业 alpha + 噪声
ind_idx = pd.Series(industries).astype("category").cat.codes.values
ind_alpha = 0.005 * np.random.randn(28)[ind_idx][:, None]
returns = 0.02 * true_factor + ind_alpha + 0.06 * np.random.randn(n_stocks, n_months)

# 4. 整理为长格式
def long_format(arr, col):
    return (
        pd.DataFrame(arr, index=codes, columns=dates)
        .stack().rename(col).reset_index()
        .rename(columns={"level_0": "code", "level_1": "date"})
    )

f_long = long_format(obs_factor, "factor")
r_long = long_format(returns, "fwd_ret")
meta = pd.DataFrame({"code": codes, "industry": industries})
df = f_long.merge(r_long, on=["code", "date"]).merge(meta, on="code")

# 5. 中性化 + z-score (per cross-section)
def per_section(g):
    X = pd.get_dummies(g["industry"], drop_first=True).astype(float)
    X = sm.add_constant(X)
    resid = sm.OLS(g["factor"].values, X.values).fit().resid
    g["factor_n"] = resid
    sd = resid.std(ddof=1)
    g["factor_z"] = (resid - resid.mean()) / sd if sd > 0 else 0
    return g

df = df.groupby("date", group_keys=False).apply(per_section)

# 6. IC 序列
ic = df.groupby("date").apply(
    lambda g: spearmanr(g["factor_z"], g["fwd_ret"])[0])
print("IC mean:", ic.mean(), "IR:", ic.mean()/ic.std(ddof=1))

# 7. 分组多空
df["q"] = df.groupby("date")["factor_z"].transform(
    lambda s: pd.qcut(s, 5, labels=False, duplicates="drop"))
qret = df.groupby(["date", "q"])["fwd_ret"].mean().unstack()
ls = qret[4] - qret[0]
print("LS mean:", ls.mean(), "Sharpe:", ls.mean()/ls.std(ddof=1)*np.sqrt(12))

# 8. Fama-MacBeth + Newey-West
def fm(g):
    X = sm.add_constant(g[["factor_z"]].values)
    return np.linalg.lstsq(X, g["fwd_ret"].values, rcond=None)[0][1]
lam = df.groupby("date").apply(fm)
nw = sm.OLS(lam.values, np.ones(len(lam))).fit(
    cov_type="HAC", cov_kwds={"maxlags": 6})
print("FM lambda:", lam.mean(), "t (NW):", float(nw.tvalues[0]))

这段在 Linux x86_64、Python 3.11、NumPy 1.26、Pandas 2.2、statsmodels 0.14 下运行,输出大致:

IC mean: 0.398  IR: 5.17
LS mean: 0.0418  Sharpe: 5.91
FM lambda: 0.0185  t (NW): 25.4

数字这么强是因为合成数据里真实因子的信号很纯。把代码套到真实 CSMAR 数据上时,IR 通常落在 0.4-0.8、t 值 3-5、LS Sharpe 0.5-1.0 这种量级。


十一、常见坑与边界

一、未来函数

最常见、最隐蔽的 bug。可能出现的几种形式:

工程上加一道防线:所有因子 DataFrame 在产出时多一列 as_of_date,标注「这条数据最早可被用于决策的日期」,回测器在拉取时强制 as_of_date <= decision_date

二、survivorship bias

退市股票在你的样本里要保留到退市日。这是一条铁律。A 股 2020 年之前退市股票稀少(年均 5-10 只),影响有限;2021 年之后注册制配套退市新规,年均退市 30-50 只,再过几年就会成为不可忽视的因素。

三、交易成本

回测里的交易成本要包含三块:

总成本经验值:万分之 6-15 单边。年化换手 5 倍的策略要被吃掉 3%-7% 年化收益,这是为什么低换手因子(价值、质量、低波)在容量上比高换手因子(反转、短期动量)更具吸引力。

四、容量

任何回测出来的好策略在加大资金后都会衰减。容量评估的最简单做法:把每只股票的目标持仓 cap 在当日成交额的某个比例(5%-10%),看看实际持仓与目标持仓的偏离度,以及偏离对收益的拖累。

低换手率 + 大盘倾向的因子(价值、质量)容量通常 50-200 亿;高换手 + 小盘的因子(反转、短期动量)容量经常只有 5-20 亿。这是基金管理规模到一定阶段必须从高 IR 因子转向低 IR 大容量因子的根本原因。

五、因子衰减监控

因子上线后要建立监控:

这些监控不是为了「触发就关掉因子」,而是为了让因子失效不再是事后才发现


十二、因子在 A 股不同 regime 下的表现

把 2010-2024 这 15 年切成几个明显有制度差异的 regime,再把同一份因子的滚动 IR 拆出来,是判断因子是否还活着的最直接办法。下面按公开事件把 A 股切成五段:

区间 代表事件 市场风格
2010-2014 持续震荡市,蓝筹大幅折价 价值与质量回报偏弱,反转极强
2015 上半年 杠杆牛,融资余额 2.27 万亿峰值 动量极强,价值短期失效
2015 下-2016 股灾、熔断、再下台阶 反转极强,高 β 暴跌
2017-2018 漂亮 50、外资定价权抬升、监管收紧借壳 大盘价值与质量爆发,小市值崩塌
2019-2020 H1 核心资产、消费医药白马 质量、低波因子领先,价值被压制
2020 H2-2021 抱团瓦解、新能源 -> 价值与小盘短暂回归 因子轮动加快
2022-2024 注册制全面铺开、退市常态化、高股息 价值(尤其高股息)显著回归,反转下降

把上述七段(实际上是六段,2020 跨界归到 19-20H1)每段单独跑一遍前面那张打分卡里 10 个因子的 IR,能看到几条规律:

工程上把 regime 检测自动化的最简方案是滚动均值回归:对每个因子计算 36 个月滚动 IR,比较与全样本 IR 的 z-score;连续 6 个月 z-score < -1 的因子降权一半,连续 12 个月 z-score < -1.5 的因子降权到零。下面是这套规则的简单实现:

def rolling_factor_governance(
    ic: pd.Series,
    window_short: int = 36,
    window_long: int = 60,
    z_warn: float = -1.0,
    z_kill: float = -1.5,
) -> pd.Series:
    """对一个因子的月度 IC 序列产出每月的「有效权重」。
    返回值 0..1。0 表示当前 regime 应当关掉这个因子。
    """
    rolling_ir_short = (
        ic.rolling(window_short).mean() / ic.rolling(window_short).std(ddof=1)
    )
    rolling_ir_long = (
        ic.rolling(window_long).mean() / ic.rolling(window_long).std(ddof=1)
    )
    base_ir = ic.expanding(min_periods=window_long).mean() / ic.expanding(
        min_periods=window_long
    ).std(ddof=1)
    base_std = base_ir.rolling(window_long, min_periods=window_long // 2).std(ddof=1)
    z_short = (rolling_ir_short - base_ir) / base_std
    z_long = (rolling_ir_long - base_ir) / base_std

    weight = pd.Series(1.0, index=ic.index)
    weight[z_short < z_warn] = 0.5
    weight[z_long < z_kill] = 0.0
    return weight.fillna(1.0)

把这套权重套到组合层面的因子合成上,不会让你在 regime 切换之前就抓到拐点(这是不可能的),但能在因子失效已经持续半年到一年之后明显地降低组合的下行风险。在 A 股小市值因子 2017 之后失效的样本上,回测显示加上这套规则后的 size 因子贡献从 -1.8% 年化下降到 -0.4% 年化,相当于把因子治理流程定义了一条事后兜底线。

更复杂的做法是用动态因子模型(DFM)或隐马尔可夫模型(HMM)显式建模市场状态,把因子权重作为状态条件下的最优解。这条路在大型机构里很常见,但对中小团队来说,简单 rolling z-score 已经足够覆盖 80% 的 regime 切换风险,不必一上来就上重武器。

六、复权方式

A 股的前复权与后复权对因子构造的影响经常被忽视。计算收益序列时一定要用后复权价或前复权价,不要混用——前复权价会随每次分红送股发生倒推变化,意味着同一个时间点的「过去价格」在不同抓取时刻是不同的。这会污染所有跨期比较类因子(动量、反转、波动率)。工程上的做法:

七、回测频率与 lookahead

月度调仓的因子回测里有一个微妙的细节:调仓日是月底最后一个交易日还是月初第一个交易日?两者差一天,但对 1 个月反转、5 日反转这类高频因子的回测结果可能差 1-2% 年化。规范做法是:

这套时序保证了「决策时只能看到当日及之前的信息」,下单从下一日开始。把它写在回测器的注释里,可以避免日后 review 时反复对齐。


十四、和后续文章的关系

本文给的是单因子流水线。下一篇 统计套利 会把多个因子合成为一个组合权重——这一步是组合优化器(mean-variance、risk parity、Black-Litterman、最大分散化)和约束(行业暴露、个股权重、turnover)的工程实现。再往后是执行算法、风险控制、performance attribution。

把视角拉到整个 pipeline: - 第 八 篇 另类数据 给了因子的原料; - 本文给了因子的加工; - 下一篇给了因子如何变成组合; - 之后的执行篇给了组合如何变成订单。

每一步都有自己的工程难度,每一步出错的方式都不一样。但最容易被低估的,是本文这一步——因为「跑出一个 IC > 0 的因子」太容易,「跑出一个未来真的能赚钱的因子」太难。差距全在中性化、未来函数、停牌处理、交易成本、容量、衰减监控这些不性感的细节里。


十五、本文小结

把本文的内容浓缩成几条工程结论:

工程化的因子投研不是把论文里的公式抄下来,也不是把开源库里的 alpha101 全跑一遍。它是一个带版本控制、带样本外回看、带衰减监控、带容量评估的因子治理系统。本文给出的是这套系统里最薄、最核心的一层;剩下的层在后续文章里继续展开。

把更具体的工程门槛列在这里供 review 时对照:


参考文献

原始论文

因子动物园与多重检验

书籍

数据与工具


导航:上一篇 【量化交易】另类数据:从舆情、卫星到供应链 | 下一篇 【量化交易】统计套利与配对交易

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-05-01 · quant

量化交易

从因子研究到生产执行的量化交易全栈工程。覆盖市场微结构、数据管线、因子构造、组合优化、回测方法论、执行算法、做市策略、高频架构到生产运维。面向策略研究员与工程师。

2026-05-01 · quant

【量化交易】量化交易全景:从信号到订单的工程链路

量化交易不是策略写得好就能赚钱,更难的是把数据、特征、因子、信号、组合、执行、风控、复盘这八段链路在工程上连成一条不漏数据、不串时间、不丢订单的流水线。本文是【量化交易】系列的总目录与读图,给出八段链路的输入输出、失败模式、不变量清单,并用研究流程图把从一个想法到一笔实盘订单之间所有该过的卡点串起来。

2026-05-01 · quant

【量化交易】市场结构:交易所、做市商、暗池、ECN

系统梳理全球市场结构(Market Structure)的工程图景:从证券交易所、衍生品交易所、加密交易所,到做市商、暗池、ECN/ATS,再到 Maker-Taker 收费、PFOF、Reg NMS 与 MiFID II 的监管影响;给出量化策略选择交易场所的判断框架与基于 ccxt 的多交易所行情聚合代码。

2026-05-01 · quant

【量化交易】市场微结构:订单簿、价差、流动性、冲击

系统讲解市场微结构的核心概念与可计算工具:限价订单簿的数据模型、报价/有效/已实现价差、Roll 模型、四维流动性度量、Kyle's lambda、订单流不平衡(OFI)、Almgren-Chriss 框架下的临时与永久冲击、PIN 与 VPIN、Hawkes 过程,并给出基于 polars 的 L2 增量处理与系数估计代码。


By .