某 私募 的研究员对两位同事说:"做一个 五日 动量,行业中性化,跑在 沪深300 的大盘股上。"一周以后,三个人各自实现了完全不同的东西。同事 A 用的是连续 五日 收益率 close_t / close_{t-5} - 1;同事 B 用的是 五日 均线穿越 MA_5 / MA_20;同事 C 用的是 五日 对数收益累加 sum(log(close_t / close_{t-1}))。三个人都声称自己实现的是 "五日 动量"。然而没有一个人 在以下三点上达成一致:行业中性化的具体口径,信号的可用时点(信号是 t 日收盘可用,还是要等到 t+1 日开盘才可下单?),以及缩尾的策略。风控委员会问到底哪个版本才是"那个"信号,诚实的回答是:都不算。文字描述本身就是含糊的,三个工程师真诚地把它理解成了三件事。修正的办法就是 Alpha 公式 DSL:一个小而完备的函数式语言,有固定的原语集合、固定的算子集合和规范的归一化流水线。一旦你说 DSL,"五日 动量"就不再是一段散文,而变成了一行任何评审都能逐字节复现的代码。
量化信号是什么
量化信号是以 (symbol, timestamp) 为索引的实值时间序列,用来预测某个时间跨度 h 内的远期收益。形式化地讲:
其中 是截至 t 时点已知的全部信息——价格、成交量、基本面、事件、另类数据——而 是信号在 t 时点对区间 的远期收益的预测。同一个信号在管线中以两种形态共存。原始信号是公式或模型的直接输出。清洗后信号是经过缩尾、排序、Z 标准化、可选行业中性化以及 T+1 滞后处理的版本,这才是真正喂给组合层的。下注引擎只会看见清洗后信号。把清洗这一步当成可选项不是讨论问题——未清洗的信号有离群值,会把回测和下注大小的方差打爆。
规则的另一半是:信号的规范化定义是公式或代码层级的,不能是散文层级的。mom_5d = delay(close, 0) / delay(close, 5) - 1 是一个信号定义;"五日 动量"不是。让两者分开的就是 DSL,本课其余部分都在讲这门 DSL。
三大信号家族
Alpha 研究里的每一条信号都必然属于三个家族之一(在 主构造层级上)。三个家族的规范顺序是:formula-driven (价量或基本面数据的数学变换——Alpha 公式传统的家园,代表作是 WorldQuant Alpha101 公式库和微软 Qlib Alpha158 公式库)、event-driven (锚定在某个离散的公司事件或市场事件上——盈利惊喜、并购、指数纳入)、ML-driven (吃进很多特征、吐出一个评分的非线性模型——梯度提升树、神经网络编码器)。三大家族在 特征层级上不是互斥的:现代 ML-driven 信号常把 formula-driven 与 event-driven 的特征一起喂给一个非线性 ranker,然后再做组合。但每条信号都只有一个主家族,由产生最终评分的那一步变换来定义。一个吃了 200 个公式化特征的 LightGBM ranker 依然是 ML-driven 信号,因为最后一步变换是树集成,不是任何一个公式。
每个家族都有自己典型的构造原语(公式、事件检测规则、训练好的模型),自己典型的泄露模式(T+1 滞后、公告日 join、purged-and-embargoed k 折),以及自己典型的评估区间。第 2 课构造公式化信号库,第 3 课构造事件驱动信号库,第 4 课构造 ML-driven 栈。本课只停留在分类层。
截面信号与时序信号的区分
与家族选择独立的另一个轴是下注结构。cross-sectional 信号在每个 t 时点对全市场打分排序,然后做多榜首、做空榜尾;这是股票因子研究里的主流模式,因为它能抵消共同的市场暴露、孤立相对排序。time-series 信号一次只看一只 标的,直接预测它自己的远期收益;这是 CTA 趋势跟踪的主流模式。同一个 DSL 公式可以两种模式都跑。12-1 月动量信号写成截面版,是 rank(ts_mean(returns, 252) - ts_mean(returns, 21)),在每个日期对全部 标的 排序后做多前五分位、做空后五分位。写成时序版,同一个公式不套 rank 直接消费,产生每只 标的 自己的仓位方向。公式化与 ML-driven 的股票因子信号默认走截面;CTA 类趋势信号默认走时序。在别人问之前就把走的是哪种讲清楚。
Alpha 公式 DSL 算子集合
DSL 是能表达每一条 Alpha101 公式与每一条 Alpha158 特征的最小函数式语言。它分三组算子,按规范顺序列出。
(1) primitives —— open、close、high、low、volume、vwap、returns。每个原语都是 (symbol, t) 索引的 OHLCV 或衍生量序列。returns 是日度简单收益率;vwap 是 bar 内成交量加权平均价。
(2) time-series operators —— ts_mean(x, d)、ts_std(x, d)、ts_min(x, d)、ts_max(x, d)、ts_rank(x, d)、ts_corr(x, y, d)、ts_cov(x, y, d)、delay(x, d)、delta(x, d)、decay_linear(x, d)、sum(x, d)、product(x, d)。每个算子吃一个序列加一个回看窗口 d,产生一个序列。ts_rank(x, d) 是同一只 标的 在过去 d 个观测内的时序排名;delay(x, d) 是把 x 滞后 d 期;decay_linear(x, d) 是窗口 d 上的线性加权移动平均。
(3) cross-sectional operators —— rank(x)、scale(x)、industry_neutralize(x)、quantile(x, n)。这些算子在单一时间点上对截面起作用。rank(x) 是 t 时点的截面排名,可选地重缩放;industry_neutralize(x) 减去同一行业内的均值。
算术算子 —— +、-、*、/、^、log、sign、abs —— 把语言补齐。任何 Alpha101 公式都能用这张算子表逐算子翻译,不用别的。
规范化归一化流水线
本模块里的每一条清洗后信号都按下面这五步流水线、严格按这个顺序执行:
winsorize(x, [0.01, 0.99])—— 把上下各 1% 的尾部截断。rank(x)—— 每个时点上的截面排名,可选重缩放到[-0.5, +0.5]。z_score(x)—— 在截面上标准化到均值 0、标准差 1。industry_neutralize(x)—— 可选但强烈推荐,减去行业内均值。T+1 lag—— 在t日收盘算出来的信号,只能从t+1日开盘开始可用,绝不能从t日收盘开始。
违反第五步是公式化研究里最常见的前视偏差;t 日收盘时点算出来的信号值,无法在下一交易日开盘之前被任何账户付诸下单。
四个诊断问题
每当一篇论文或预印本提出一条新信号,在你花一小时去复现之前,先问四个问题。(1) What is the DSL formula? —— 文字描述不是信号;论文不写出公式,你就无法复现。(2) Is it cross-sectional or time-series? —— 这决定下注结构与 universe 过滤。(3) Which family is it — formula / event / ML? —— 这决定构造原语和你需要防御的泄露模式。(4) What is the T+1-lag policy? —— 这决定论文是否带前视偏差。这四问对应四个性质:可复现性、下注结构、家族归属、前视防御。
工作例:解码 Alpha101 第 101 条
Alpha101 里最简单的一条是 Alpha#101 = (close - open) / ((high - low) + 0.001) —— 当日日内收益除以当日振幅。+ 0.001 是给振幅为零的 bar 做除零保护。写成 pandas 函数:
def alpha_101(open_, high, low, close):
# (close - open) / ((high - low) + 0.001)
# divide-by-zero guard: 0.001 防止零振幅 bar 引起除零
return (close - open_) / ((high - low) + 0.001)
同一条公式用 DSL 写就是一行。注意公式假定输入是原始 OHLCV,归一化流水线是在第二趟单独应用的。
工作例:清洗流水线
规范化清洗以一个函数形式把五步流水线串起来,签名如下:
def clean_signal(raw_signal, industry_codes):
# 1. winsorize at [0.01, 0.99] cross-sectionally
s = raw_signal.clip(lower=raw_signal.quantile(0.01),
upper=raw_signal.quantile(0.99), axis=1)
# 2. rank cross-sectionally and rescale to [-0.5, +0.5]
s = s.rank(axis=1, pct=True) - 0.5
# 3. z-score cross-sectionally
s = (s.sub(s.mean(axis=1), axis=0)).div(s.std(axis=1), axis=0)
# 4. industry-neutralize by industry_codes
s = s.sub(s.groupby(industry_codes, axis=1).transform('mean'))
# 5. T+1 lag —— t 日收盘信号,t+1 日开盘可用
return s.shift(1)
本模块所有清洗后信号都要把公式或模型的原始输出过一遍这个函数。这五步不容妥协。
与方法论的衔接
研究方法模块里讲过的试验计数纪律在这里同样生效。每一条你拿来在 沪深300 上跑的 Alpha101 公式,每一次你拨动的回看窗口,每一次你调换的归一化口径,都会让总计数器加一,写稿时的 deflated-Sharpe 校正会把它统统惩罚回来。DSL 不会改变计数器的数字,它只是让被计数的东西彼此可比、彼此明确。能抓住四类泄露模式的样本切分纪律来自样本内/样本外那一课,本课假定你已经用上了。一条公式化信号的样本内月度 IC 通常落在 0.02 到 0.05 之间,t 统计量在 2 到 4 之间;这些术语用法已经够本课用,完整的 IC / IR / 衰减曲线 / 换手率 / 容量评估框架是下个模块"信号评估与组合"的内容。
继续读下去,第 2 课会把你刚学到的 DSL 与清洗流水线拿去构造规范的价量与基本面公式库:12-1 月动量、五日反转、低波动倾斜、价值倾斜、质量倾斜、应计、资产增速。那里每一条信号都是一行 DSL 加一张敏感性测试表。
本课组件
Inline-code listing of the three canonical signal families:formula-driven、event-driven、ML-driven —— 模块内每条信号都只属于一个 主家族,但现代 ML 信号同时消费 formula-driven 加 event-driven 特征。Inline-code listing of the alpha-formula DSL operator set:primitives open、close、high、low、volume、vwap、returns;time-series operators ts_mean(x, d)、ts_std(x, d)、ts_min(x, d)、ts_max(x, d)、ts_rank(x, d)、ts_corr(x, y, d)、ts_cov(x, y, d)、delay(x, d)、delta(x, d)、decay_linear(x, d)、sum(x, d)、product(x, d);cross-sectional operators rank(x)、scale(x)、industry_neutralize(x)、quantile(x, n)。Inline-code listing of the five-step normalisation stack:winsorize(x, [0.01, 0.99])、rank(x)、z_score(x)、industry_neutralize(x)、T+1 lag。Inline-code listing of the four diagnostic questions:What is the DSL formula?、Is it cross-sectional or time-series?、Which family is it — formula / event / ML?、What is the T+1-lag policy?。两段 fenced python:alpha_101 解码与 clean_signal 清洗流水线。一个 Exercise,两个 Hint。Region anchors: 沪深300 大盘 universe。本课在词汇层面点到的因子标签包括 动量、价值因子、质量因子、规模因子、低波动因子;还包括 因子模型 框架的一次提及、因子暴露 的一次引用与 Alpha 衰减 的一次引用,都向后续模块转交。
练习
Exercise
你拿到一篇论文,文字描述如下:"五日 截面动量,行业中性化,跑在 沪深300 的大盘股上。" (i) 把这条信号写成 Alpha 公式 DSL 的一行,每一个算子都明确列出,并尊重规范化的 T+1 滞后(信号在 t+1 日开盘可用,而不是 t 日收盘)。(ii) 把信号归入三大家族之一(formula-driven / event-driven / ML-driven),用一句话说理由。(iii) 说明这条信号是 cross-sectional 还是 time-series,用一句话说理由。(iv) 写出你会应用的五步归一化流水线,按顺序列出每一步与其参数值。(v) 指出两个不小心就会引入的前视 bug,以及每个 bug 违反了哪个 DSL 算子。把题目落在 沪深300 universe 上,样本期 2018 到 2023。
提示
delay(close, 0) / delay(close, 5) - 1 出发,先套截面 rank,再套 industry_neutralize,最后做滞后。滞后是在末尾单独的 .shift(1),不是公式的一部分。提示
close_t 去预测 close_t 时点入场的仓位是一个 bug;把基本面用 fiscal-period-end 去 join 而不是用公告日,是另一个。