远思为径,码动价值。欢迎来到远思码技术实验室的最新技术实践记录。
在上一篇数据抓取里,我们已经把 A 股日线行情写进了 TimescaleDB 的超表。但干净的数据不等于可用的信号——从"能取到数据"到"能下单",中间隔着整个因子工程。
这篇文章把我们实验室的因子流水线拆开讲:怎么写、怎么校验、怎么避免自己骗自己。
一、先立四条纪律
因子工程里绝大多数翻车,不是因为数学不够花哨,而是因为违反了下面前四条之一:
- 不使用未来信息。T 日收盘后才能拿到的数据,只能在 T 日收盘后生成信号,T+1 开盘执行。任何
center=True的滚动窗口、任何全样本拟合的标准化,都是未来函数。 - 截面可比。因子值必须在同一个交易日的横截面上做标准化,而不是用时间序列均值。
- 可复现。因子定义、参数、数据版本都写进代码与因子表,重跑必须得到同样的数字。
- 可检验。一个因子在入库前必须回答两个问题:它对未来收益有没有单调信息(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_20 | 20 日收益率标准差取负 | 低波动异象 |
turn_20 | 20 日平均换手取负 | 高换手 = 拥挤 |
bias_20 | close / 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 也是全样本。真正的检验要在滚动窗口里做,下一篇会展开。
结语
到这里,我们有了一个可复现、可检验的因子表。接下来两件事必须按顺序做:先把回测引擎写对,再用它去证伪这些因子——先有可信的尺子,再去量东西。
远思为径,码动价值。数据给了原料,因子给了假设,剩下的交给回测去审判。