因子工程实战:从行情数据到可检验的 Alpha 因子

承接 Akshare 数据入库,把日行情加工成可检验的 Alpha 因子:去极值、标准化、行业中性化,以及 IC / RankIC / ICIR 与分层回测的完整流水线,最后落成一张可复用的因子表。

远思为径,码动价值。欢迎来到远思码技术实验室的最新技术实践记录。

在上一篇数据抓取里,我们已经把 A 股日线行情写进了 TimescaleDB 的超表。但干净的数据不等于可用的信号——从"能取到数据"到"能下单",中间隔着整个因子工程。

这篇文章把我们实验室的因子流水线拆开讲:怎么写、怎么校验、怎么避免自己骗自己。

一、先立四条纪律

因子工程里绝大多数翻车,不是因为数学不够花哨,而是因为违反了下面前四条之一:

  1. 不使用未来信息。T 日收盘后才能拿到的数据,只能在 T 日收盘后生成信号,T+1 开盘执行。任何 center=True 的滚动窗口、任何全样本拟合的标准化,都是未来函数。
  2. 截面可比。因子值必须在同一个交易日的横截面上做标准化,而不是用时间序列均值。
  3. 可复现。因子定义、参数、数据版本都写进代码与因子表,重跑必须得到同样的数字。
  4. 可检验。一个因子在入库前必须回答两个问题:它对未来收益有没有单调信息(IC / 分层),扣掉成本后还剩多少(换手)。

第 4 条最容易被跳过,而它恰好是唯一真正重要的一条。

二、取数:把宽表拉成矩阵

实验室的行情表结构在上一篇文章里已经建好,核心字段是 symbol / trade_date / close / volume / amount / turnover / pct_chg。因子计算更适合"矩阵"而不是"长表",所以第一步先 pivot:

# src/data/market.py
from __future__ import annotations

import pandas as pd
from sqlalchemy import create_engine

DSN = "postgresql+psycopg2://quant:quant@127.0.0.1:5432/quant"


def load_price_matrix(start: str, end: str, engine=None) -> dict[str, pd.DataFrame]:
    """返回 {'close','volume','amount','turnover','pct_chg'} 五张 T×N 矩阵。"""
    engine = engine or create_engine(DSN, pool_pre_ping=True, pool_size=4)
    sql = """
        SELECT symbol, trade_date, close, volume, amount, turnover, pct_chg
        FROM daily_bar
        WHERE trade_date BETWEEN %(start)s AND %(end)s
        ORDER BY trade_date, symbol
    """
    df = pd.read_sql(sql, engine, params={"start": start, "end": end})

    out: dict[str, pd.DataFrame] = {}
    for col in ["close", "volume", "amount", "turnover", "pct_chg"]:
        out[col] = df.pivot(index="trade_date", columns="symbol", values=col).sort_index()
    # 停牌日通常缺失,这里保留 NaN:后续因子计算里 NaN 会自然传播成"不可交易"
    return out

两个细节值得强调:

  • 不要 fillna(method="ffill") 无脑填充。停牌日的价格是"没有成交",强行前向填充会让动量因子把停牌期算成 0 收益,回测里会凭空产生收益。
  • 矩阵的列(股票)会随时间变化。新股上市、退市都会造成列不对齐,后续用 DataFrame.align 或直接按日截面处理。

三、五个基础因子,先够用再说

不用一上来就上百个因子。实验室的第一版只用了五类,覆盖量价四个维度:

因子定义逻辑
mom_20过去 20 日收益动量效应
rev_5过去 5 日收益取负短期反转
vol_2020 日收益率标准差取负低波动异象
turn_2020 日平均换手取负高换手 = 拥挤
bias_20close / MA20 - 1 取负均值回复
# src/factor/basic.py
import numpy as np
import pandas as pd


def momentum(close: pd.DataFrame, n: int = 20) -> pd.DataFrame:
    """n 日动量:T 日因子 = T 日收盘 / T-n 日收盘 - 1(截止当日,不含未来信息)。"""
    return close / close.shift(n) - 1


def reversal(close: pd.DataFrame, n: int = 5) -> pd.DataFrame:
    return -momentum(close, n)


def volatility(pct: pd.DataFrame, n: int = 20) -> pd.DataFrame:
    # pct_chg 已是百分数,先转成小数
    return -(pct / 100).rolling(n).std()


def turnover_avg(turnover: pd.DataFrame, n: int = 20) -> pd.DataFrame:
    return -turnover.rolling(n).mean()


def bias(close: pd.DataFrame, n: int = 20) -> pd.DataFrame:
    ma = close.rolling(n).mean()
    return -(close / ma - 1)

上面每个因子都只用「截止当日」的数据,这是故意保持的语义:因子定义里不包含任何执行滞后,滞后只在回测引擎里统一做一次。原因很实际——如果因子内部先 shift(1),回测引擎再叠一层 shift(1),策略就凭空慢了半拍,收益会被系统性低估;反过来如果某处漏了 shift,又会偷到未来收益。滞后只在一处发生,才方便审计。下一篇的引擎会严格实现这条约定。

四、去极值与标准化:截面处理三步

原始因子值直接进模型会被几只极端值股票带偏。截面处理固定三步:

# src/factor/process.py
import numpy as np
import pandas as pd


def mad_winsorize(df: pd.DataFrame, n: float = 3.0) -> pd.DataFrame:
    """按日截面做 MAD 去极值(中位数 ± n * 1.4826 * MAD)。"""
    med = df.median(axis=1)
    mad = (df.sub(med, axis=0)).abs().median(axis=1) * 1.4826
    upper = med + n * mad
    lower = med - n * mad
    return df.clip(lower=lower, upper=upper, axis=0)


def zscore(df: pd.DataFrame) -> pd.DataFrame:
    """按日截面做 z-score。std=0(全市场同一个值)时返回 0 而不是 inf。"""
    mu = df.mean(axis=1)
    sd = df.std(axis=1).replace(0, np.nan)
    return df.sub(mu, axis=0).div(sd, axis=0)


def industry_neutralize(factor: pd.DataFrame, industry: pd.DataFrame) -> pd.DataFrame:
    """行业中性化:对每个交易日,把因子对行业哑变量做 OLS,取残差。

    industry: 与 factor 同形状的行业标签矩阵(如 "银行"、"电子"),缺失记为 NaN。
    """
    residual = factor.copy() * np.nan
    for date in factor.index:
        y = factor.loc[date]
        g = industry.loc[date]
        mask = y.notna() & g.notna()
        if mask.sum() < 30:
            continue
        dummies = pd.get_dummies(g[mask], drop_first=True, dtype=float)
        if dummies.empty:
            residual.loc[date] = y[mask]
            continue
        X = np.column_stack([np.ones(mask.sum()), dummies.to_numpy()])
        yv = y[mask].to_numpy()
        beta, *_ = np.linalg.lstsq(X, yv, rcond=None)
        residual.loc[date, mask] = yv - X @ beta
    return residual

顺序很重要:先 MAD 去极值 → 再行业中性化 → 最后 z-score。如果先标准化再中性化,残差的量纲会乱,后面做因子加权时权重就失去意义了。

提醒:行业中性化一定会"吃掉"一部分收益。如果你的因子本来就是行业轮动逻辑(比如"超配银行"),中性化之后就只剩行业内选股能力了——这不是坏事,只是要清楚自己在测什么。

五、检验:IC、分层、换手,一个都不能少

因子是否有效,实验室只用三个指标下结论。

5.1 IC 与 ICIR

# src/factor/evaluate.py
import numpy as np
import pandas as pd
from scipy.stats import spearmanr


def forward_return(close: pd.DataFrame, hold: int = 5) -> pd.DataFrame:
    """未来 hold 日收益(用于评价,绝不进入因子计算)。"""
    return close.shift(-hold) / close - 1


def daily_ic(factor: pd.DataFrame, fwd_ret: pd.DataFrame, method: str = "spearman") -> pd.Series:
    """每个交易日横截面上的因子与未来收益的相关性。"""
    ics = {}
    for date in factor.index:
        x, y = factor.loc[date], fwd_ret.loc[date]
        mask = x.notna() & y.notna()
        if mask.sum() < 50:
            continue
        if method == "spearman":
            ics[date] = spearmanr(x[mask], y[mask]).statistic
        else:
            ics[date] = np.corrcoef(x[mask], y[mask])[0, 1]
    return pd.Series(ics).sort_index()


def ic_summary(ic: pd.Series, periods_per_year: int = 244) -> dict:
    ir = ic.mean() / ic.std(ddof=1) if ic.std(ddof=1) else np.nan
    return {
        "IC均值": round(float(ic.mean()), 4),
        "IC标准差": round(float(ic.std(ddof=1)), 4),
        "ICIR": round(float(ir), 3),
        "IC>0占比": round(float((ic > 0).mean()), 3),
        "年化ICIR": round(float(ir * np.sqrt(periods_per_year / 5)), 2) if ir == ir else None,
        "样本天数": int(len(ic)),
    }

经验阈值(日频、5 日持有):|IC均值| > 0.02、ICIR > 0.3、IC > 0 占比 > 0.55 才值得往下做分层测试。低于这个水平,后面所有回测都只是在拟合噪声。

5.2 分层收益

def layered_return(factor: pd.DataFrame, fwd_ret: pd.DataFrame, q: int = 5) -> pd.DataFrame:
    """按日截面分 q 组,等权持有 forward 收益,返回每组的年化收益。"""
    groups: dict[int, list[float]] = {i: [] for i in range(q)}
    for date in factor.index:
        x, y = factor.loc[date], fwd_ret.loc[date]
        mask = x.notna() & y.notna()
        if mask.sum() < q * 10:
            continue
        labels = pd.qcut(x[mask].rank(method="first"), q, labels=False)
        for g, idx in labels.groupby(labels).groups.items():
            groups[int(g)].append(float(y[mask].loc[idx].mean()))
    annual = {}
    for g, rets in groups.items():
        s = pd.Series(rets)
        annual[f"Q{g + 1}"] = round(float(s.mean() * (244 / 5)), 4) if len(s) else None
    return pd.DataFrame([annual], index=["年化收益"])

一个真正有效的因子,Q1 → Q5 应该单调,并且首尾组年化差异是稳定的,而不是靠某一年撑起来的。

5.3 换手率

IC 再漂亮,如果因子每天把整个组合换个遍,扣完成本就归零。实验室的标准做法是算因子秩的日间自相关来近似换手:

def rank_autocorr(factor: pd.DataFrame, lag: int = 1) -> float:
    """因子截面秩的自相关,越高越稳定(0.9 以上说明换手很低)。"""
    ranks = factor.rank(axis=1, pct=True)
    vals = []
    for date in ranks.index[lag:]:
        a = ranks.loc[date]
        b = ranks.shift(lag).loc[date]
        mask = a.notna() & b.notna()
        if mask.sum() < 50:
            continue
        vals.append(float(np.corrcoef(a[mask], b[mask])[0, 1]))
    return float(np.nanmean(vals))

六、落库:因子表长什么样

因子算完不要停在 notebook 里。实验室统一写入 factor_value 表,主键 (factor_name, trade_date, symbol),字段 raw / z,外加版本号:

CREATE TABLE IF NOT EXISTS factor_value (
    factor_name  text        NOT NULL,
    trade_date   date        NOT NULL,
    symbol       text        NOT NULL,
    raw          double precision,
    z            double precision,
    version      text        NOT NULL DEFAULT 'v1',
    PRIMARY KEY (factor_name, trade_date, symbol)
);

SELECT create_hypertable('factor_value', 'trade_date',
    chunk_time_interval => INTERVAL '90 days',
    if_not_exists => TRUE);

写入时用 ON CONFLICT DO UPDATE 做幂等 upsert,这样重跑某一天的因子不会产生重复行:

from sqlalchemy import text

UPSERT = text("""
    INSERT INTO factor_value (factor_name, trade_date, symbol, raw, z, version)
    VALUES (:factor_name, :trade_date, :symbol, :raw, :z, :version)
    ON CONFLICT (factor_name, trade_date, symbol)
    DO UPDATE SET raw = EXCLUDED.raw, z = EXCLUDED.z, version = EXCLUDED.version
""")

七、几个一定会踩的坑

  • 复权基准会变。前复权价格在每次除权后整体重算,历史因子值会"漂移"。实验室的做法是因子表存未复权价 + 复权因子,复权只在计算时做,并且记录 version。
  • ST / 退市股没剔除。z 值算对了,但买不进去。因子入库前就该把"不可交易"标出来,别等到回测才过滤。
  • 成分股用的是今天的名单。这是幸存者偏差的经典来源,做指数增强类策略时必须用当期成分股快照。
  • IC 算的是样本内。因子是全样本算出来的,IC 也是全样本。真正的检验要在滚动窗口里做,下一篇会展开。

结语

到这里,我们有了一个可复现、可检验的因子表。接下来两件事必须按顺序做:先把回测引擎写对,再用它去证伪这些因子——先有可信的尺子,再去量东西。

远思为径,码动价值。数据给了原料,因子给了假设,剩下的交给回测去审判。