某 私募 量化部周一晨会上,PM 把任务排了下来:"沪深300 上跑一套完整的量价加基本面公式库,12 月底之前要给出每一条的样本内 IC、回看敏感性和换手率。"你点头接下任务,转身坐到屏幕前——上一课刚学过的 DSL 与清洗流水线就是你今天的工具。本课要做的,就是把工业界与学术界十年下来已经沉淀好的规范公式库一条一条搬上来,每条信号要(a)能用 DSL 写成一行,(b)有原始学术出处,(c)有一个典型的月度 IC 量级数,(d)清楚地标好回看、滞后、归一化这些构造细节。本课不教评估指标的统计意义(那是下一模块),只把"怎么把信号构造出来"这件事讲透。
价量信号家族
价量家族的输入只有 OHLCV,任何账户都能拿到,没有 PIT 麻烦,所以是公式化信号最干净的起手式。规范库按下面这个顺序列出六条:
(1) mom_12_1 = ts_mean(returns, 252) - ts_mean(returns, 21) —— 12-1 月截面动量(Jegadeesh & Titman 1993;Asness 等 2013 把它推广到全球)。跳过最近一个月是为了避开短期反转。
(2) rev_5d = -(close / delay(close, 5) - 1) —— 五日 短期反转。注意前面的 负号:近一周涨得多的标的,下一周倾向于回调,所以信号要取反。
(3) vol_60 = ts_std(returns, 60) —— 60 天已实现波动率,取反后是低波动效应(Ang-Hodrick-Xing-Zhang 2006)。
(4) volume_zscore = (volume - ts_mean(volume, 20)) / ts_std(volume, 20) —— 异常成交量的 Z 评分,通常用作流动性变化的代理。
(5) obv —— On-Balance Volume,Granville 1963。递推式 obv_t = obv_{t-1} + sign(returns_t) * volume_t,把每日方向性资金流累加起来。
(6) rsi_14 —— 14 日相对强弱指标,Wilder 1978。
每一条都有自己的样本内月度 IC 量级(单条公式化信号典型在 0.02 到 0.05 之间),实现写法只占一行 DSL。再啰嗦一遍:在 沪深300 这种 universe 上,所有价量信号默认按 T+1 时滞处理。
基本面信号家族
基本面家族的输入来自财务报表,所以必须用上 PIT 数据纪律(数据基础设施那一课讲过)。规范库按下面这个顺序列出五大族:
(1) value_bm = log(book_equity / market_cap) —— 价值倾斜,Fama-French 1992 的 HML 因子的原型。注意是 log,不是原始比率,这样可以把分布拉对称。
(2) quality_gpa = (revenue - cogs) / total_assets —— 毛利率,Novy-Marx 2013。结构上是把毛利除以总资产,而不是除以销售额。
(3) quality_accruals = (net_income - operating_cashflow) / total_assets —— 应计,Sloan 1996,取反。应计高的公司"赚钱不收钱",未来表现往往拖后腿,所以信号要倒过来。
(4) growth_sales = sales_ttm / delay(sales_ttm, 252) - 1 —— 营业收入同比增速。
(5) investment_ag = total_assets / delay(total_assets, 252) - 1 —— 资产增速,取反,对应 Fama-French 五因子里的 CMA(保守减激进)。资产急速扩张的公司未来收益更弱。
除上面五大族外,还有两个常用补充:value_ep = earnings_ttm / market_cap (盈利收益率),quality_roe = net_income_ttm / book_equity (净资产收益率)。前者是另一条价值口径,后者是另一条质量口径。每一条都按 PIT 公告日 join 到信号面板,不能按 fiscal-period-end 直接 join。
五个构造细节字段
每一条公式化信号都必须显式声明五个字段,缺一不可:
universe—— 大盘 vs 全体。lookback_window—— 主值加敏感性带。rebalance_frequency—— 日 / 周 / 月。normalisation_stack—— winsorize -> rank -> z-score -> industry-neutralize -> T+1 lag,来自第 1 课。lag—— 价量信号默认 T+1;基本面信号按公告日 PIT。
本模块每一条公式化信号都要写齐这五项,否则不算可复现。把动量写成正式公式:
block 公式是 DSL 一行 ts_mean(returns, 252) - ts_mean(returns, 21) 的形式化对应。
两类规范化的泄露 bug
公式化信号最常见的两个前视 bug,按出现频率排序:
(1) T+1 lag violation —— 在 t 日收盘时点算出信号,直接在 t 日收盘成交,等同于看着收盘价做收盘单。修复是在产出信号后追加 .shift(1):t 日收盘信号在 t+1 日 opening auction 才可用。
(2) PIT-data violation —— 把 Q3 公告的盈利数字按报告期末时间戳 2023-09-30 join 到信号面板;真实情况是这份数据要等到公告日 2023-11-10 之后才公开可用。修复是把数据落库时同时存 announcement_date,信号 join 用 announcement_date,绝不用 period_end_date。
这两条是公式化信号里最常见的两类前视 bug,实战里几乎每次代码审查都会逮到一个。
工作例:12-1 月动量端到端
下面把 12-1 月动量从原始价格到清洗后信号一口气写完。函数签名固定,实现按七步走:
def momentum_12_1(prices: pd.DataFrame, industry_codes: pd.Series) -> pd.DataFrame:
# 1. 从收盘价算简单收益
returns = prices.pct_change()
# 2. 12-1 月动量 —— 252 日均减去 21 日均
mom = returns.rolling(252).mean() - returns.rolling(21).mean()
# 3. 截面 winsorize at [0.01, 0.99]
lo = mom.quantile(0.01, axis=1)
hi = mom.quantile(0.99, axis=1)
mom = mom.clip(lower=lo, upper=hi, axis=0)
# 4. 截面 rank,重缩放到 [-0.5, +0.5]
mom = mom.rank(axis=1, pct=True) - 0.5
# 5. 截面 z-score
mom = mom.sub(mom.mean(axis=1), axis=0).div(mom.std(axis=1), axis=0)
# 6. 行业中性化 by industry_codes
mom = mom.sub(mom.groupby(industry_codes, axis=1).transform('mean'))
# 7. T+1 滞后 —— 信号在 t+1 日开盘可用
return mom.shift(1)
这就是一条完整的 cleaned signal。把它跑在 沪深300 上, 单一日期的截面 rank IC 对 21 日远期截面收益应当落在 0.02 到 0.05 之间,与文献口径一致。
回看敏感性测试
回看窗口是公式化信号最容易过拟合的一个超参,所以必须做敏感性测试。对 12-1 月动量,标准做法是把 (lookback_days, skip_days) 这一对参数在一个邻域上扫一遍,典型五点是:
# 12-1 月动量回看敏感性 —— (lookback_days, skip_days)
lookback_grid = [(126, 21), (189, 21), (252, 21), (378, 21), (504, 21)]
for lb, sk in lookback_grid:
ic = compute_rank_ic(momentum_lb_sk(prices, lb, sk), forward_returns_21d)
print(lb, sk, ic)
诠释规则一句话写在这里:"a genuine signal shows a monotone-and-smooth IC across the band; an overfit signal shows a sharp peak at the published value"——真信号在邻域上是单调而平滑的,过拟合信号会在公开发表的那一点附近出现尖峰。每一次扫一个新值,4.2.1 第 3 课的 trial counter 就要加一;写稿时 deflated-Sharpe 校正用的是这个总数。
用因子模型的话术讲一遍
价量与基本面公式库里的每一条信号都对应着一个常见因子标签:mom_12_1 对应 动量;value_bm 与 value_ep 对应 价值因子;quality_gpa、quality_accruals、quality_roe 对应 质量因子;vol_60 取反对应 低波动因子;investment_ag 对应资产投资因子。市值本身(size 的代理)是 规模因子,虽然本课没把它列进公式库,但在大多数 因子模型 里它和价值一起被当成两个最早的横截面解释变量。因子标签是因子模型框架的入门语言,本课只在词汇层面用到它们;完整的 因子暴露(factor loading)估计、Fama-French 五因子的工厂化拼装、China A-shares 上的实证表现,留给后面"因子库与构造"模块讲。
Alpha 衰减 也只在词汇层面被点到。一条公式化信号的衰减由换手率决定,而换手率由回看窗口与再平衡频率决定。如果你拿 mom_12_1 跑月度再平衡,典型月换手率在 30% 左右;短期反转那条信号一周换一次,月换手率会冲到 200% 上下。每多一个百分点的换手,都要在交易成本里付出来一刀。下个模块会把 IC、IR、衰减曲线、换手率与容量一起讲透;本课只把这件事点到。
共同的边界与 universe 过滤
每一条公式化信号都要在落地之前过一遍 universe 过滤。沪深300 默认就是已经做过过滤的版本——只保留过去半年内日均成交额排前 300 的 标的,过滤掉 ST、停牌、上市未满一年、流动性低于阈值的样本。如果你跑全部 沪深 universe,你需要自己把这些过滤加上去。注意涨跌停那一天的样本要特别小心:那一天的 close 不是真正的市场出清价,如果你用 close 当输入,信号会被严重扭曲。规范处理是:涨跌停日的 returns 直接置为 NaN,或者用 vwap 替代 close,反正不能让一个 +10% 的涨停硬涨进信号里。
universe 过滤的另一个常见 bug 是 survivorship bias —— 用现在仍在 沪深300 里的 标的 列表回放历史。这条 universe 在 2015 年的成员名单和今天完全不一样。规范做法是用 历史时点的 沪深300 成员名单按月或按季滚动地重建样本。这个数据通常要从指数公司直接订,免费源往往只有"现在的成员"快照。
端到端测试一条信号需要的样例代码
把上面这些规则压缩到一段端到端的代码里看一下样子。下面这个例子不构造新信号,而是把 12-1 月动量与 value_bm 两条信号在一个统一的接口下跑出来,做单一日期的截面 IC 对比:
# 把任意一条公式化信号拿来跑同样的诊断
def diagnose_signal(signal: pd.DataFrame, forward_returns: pd.DataFrame, date) -> float:
s_t = signal.loc[date].dropna()
r_t = forward_returns.loc[date].reindex(s_t.index).dropna()
common = s_t.index.intersection(r_t.index)
return s_t.loc[common].rank().corr(r_t.loc[common].rank())
# 同一天、同一 universe、同一前瞻 horizon —— 直接看哪个信号在这一天更有效
ic_mom = diagnose_signal(mom_12_1, fwd_21d, '2023-11-30')
ic_val = diagnose_signal(value_bm, fwd_21d, '2023-11-30')
把这个 diagnose_signal 跑成一年的时序,得到的就是 rank IC 时间序列,下个模块会把这条 IC 曲线展开成 IR、衰减曲线、半衰期。本课只用它做一个 诊断级 的烟雾测试:如果你的信号在 2018 到 2023 的样本上某一天的截面 rank IC 是 +0.15,那要么你做对了,要么你有前视 bug。两个都要查。
衔接
到这里你有了一条完整的公式化信号库——价量六条加基本面五大族——以及把任何一条信号从公式到清洗后输出的全套流水线。下一课会切到事件驱动家族:稀疏的事件面板、可用日 join、衰减函数。这是个完全不一样的数据结构,常见 bug 也完全不一样,但 DSL 与五步清洗流水线照旧适用。事件驱动那一课的数据结构会比这一课稀疏得多——价量与基本面是 dense panel,每个 (symbol, t) 都有一行;事件驱动是 sparse event panel,只有 (symbol, announcement_date) 的元组才有一行,要 forward-fill 到 dense panel 上才能用。换面板就换 bug,但 DSL 与五步清洗保持不变。
本课组件
Inline-code listing of the six price-volume signals:mom_12_1、rev_5d、vol_60、volume_zscore、obv、rsi_14。Inline-code listing of the five fundamental-signal families:value_bm、quality_gpa、quality_accruals、growth_sales、investment_ag。Inline-code listing of the five construction-detail fields:universe、lookback_window、rebalance_frequency、normalisation_stack、lag。Inline-code listing of the two canonical leakage bugs:T+1 lag violation 与 PIT-data violation。Fenced python 段:momentum_12_1 端到端函数与 [(126, 21), (189, 21), (252, 21), (378, 21), (504, 21)] 五点回看敏感性表。账面市值比 value_bm = log(book_equity / market_cap) 是练习的 running 工作例,PIT 对照日 2023-09-30 (fiscal-period-end) 与 2023-11-10 (announcement date)。一个 Exercise,两个 Hint。上面 KaTeX block 公式给动量的形式化表达;清洗流水线与第 1 课的 clean_signal 字节一致。Region anchors:沪深300 大盘 universe 与公告日 PIT 纪律。本课在词汇层涉及的因子标签:动量、价值因子、质量因子、规模因子、低波动因子;以及 因子模型 框架、因子暴露 与 Alpha 衰减 的引用,都向后续模块转交。
Fenced python 敏感性测试提醒:敏感性网格每多扫一个值,4.2.1 第 3 课的 trial counter 加一,写稿时的 deflated-Sharpe 校正用的是总数。
练习
Exercise
在 沪深300 universe 上,基于 2023-11-30 这一天,端到端实现 账面市值比价值信号 value_bm = log(book_equity / market_cap)。(i) 写齐五个构造细节字段:universe 过滤、回看窗口、再平衡频率、归一化流水线、滞后口径。(ii) 说明 book_equity 这一基本面输入的 PIT 可用规则:Q3-2023 的账面价值数,要按什么时间戳 join 到 2023-11-30 的信号面板?为什么?(iii) 写出清洗流水线 Python 函数,严格按五步归一化执行 (winsorize -> rank -> z_score -> industry_neutralize -> .shift(1))。(iv) 指出两个不小心就会引入的前视 bug,一个针对 T+1 滞后,一个针对 PIT 数据 join,并写出各自的修复办法。(v) 给出五个回看时点(最新季报 / 半年报 / 年报 / 滞后两个季度 / 滞后四个季度)的 book_equity 敏感性表,并解释为什么单调 IC 才是真信号的诊断信号。
提示
提示
log(book_equity / market_cap)。前提是 book_equity 已经按公告日做过 PIT join。