用2025年的数据, 写一个基础动量因子分析框架,用于参加因子大赛
策略准备,工作流创建
- 工作流;
- 仿真结果;
1.2 策略分析
################################## 策略分析和解读 #######################################
- 因子金融含义与整体设计
这个因子 Momentum20DTrimmedZscoreFactor 的核心思想:
用 20日累计收益率 度量动量(momentum);
对截面极端值做 缩尾(winsorize) 处理,降低极端值对截面标准化的影响;
对缩尾后的值做 截面 Z-score 标准化,便于与其他因子组合和比较;
对异常值(极大极小)做保护性处理,防止数值爆炸导致回测报错。
这是一个比较标准、且相对稳健的截面动量因子设计框架:
使用过去 20 日收益率捕捉短期趋势;
用缩尾 + 标准化提高鲁棒性和可组合性;
避免使用未来数据,符合因子回测的基本规范。
从金融逻辑上看,这个因子要表达的就是:
在过去 20 日价格上涨多且不太极端的股票,因子值更高;跌得多的股票因子值更低。
- 代码逐行逻辑分析
(1) 计算20日动量
close = factors[‘close’]
mom20 = RETURNS(close, 20)
RETURNS(close, 20) 等价于 (close / DELAY(close, 20)) - 1,使用的是 过去 20 日的价格信息,不会用到未来数据,时间维度是安全的。
这一步得到的是 20日累计收益率,适合作为动量的基础度量(相对 20 日简单收盘价差更规范)。
(2) 企图做截面缩尾前的“均值/标准差”
eps = 1e-8
cross_mean = TS_MEAN(mom20, 1) # 截面均值在回测引擎中会按截面计算
cross_std = STDDEV(mom20, 1)
cross_std = IF(cross_std < eps, eps, cross_std)
z_raw = (mom20 - cross_mean) / cross_std
z_trim = MIN(MAX(z_raw, -3.0), 3.0)
这里有几个需要特别说明的点:
TS_MEAN/STDDEV(…, 1) 的含义:
TS_MEAN(X, 1) 在通常时序定义里,是“过去 1 期的均值”,本质上等于 X 自己,不会产生真正的均值平滑。
同理,STDDEV(X, 1) 一般也是零(或NaN),因为 1 个样本是没有标准差的。
代码中的注释:
截面均值在回测引擎中会按截面计算
说明你是希望:
把 mom20 看成一个 截面序列(同一交易日,不同股票);
TS_MEAN(mom20, 1) 在引擎内部会被解释成 截面均值,而非时间序列均值;
如果这个行为是你们引擎的特性(即:参数=1时转为截面操作),那逻辑上就通了——
当日所有股票的 mom20 的平均值 = cross_mean;
当日所有股票的 mom20 的标准差 = cross_std;
从而 (mom20 - cross_mean) / cross_std 就是 截面 Z-score。
但从函数文档来看:
TS_MEAN(X, N) 和 STDDEV(X, N) 是典型的 时序滚动函数,一般是对每个股票自己随时间滚动计算;
截面操作应该是 RANK、SCALE、ZSCORE,它们天然按截面计算。
所以,这里最大的潜在风险是:
如果引擎并没有做“参数=1时按截面算均值/标准差”的特殊实现,那么:
TS_MEAN(mom20, 1) 实际上就是 mom20 本身;
STDDEV(mom20, 1) 可能是 0 或 NaN;
结果 z_raw 要么接近 0,要么是异常值,整个缩尾步骤会失去意义。
eps 的使用:
cross_std = IF(cross_std < eps, eps, cross_std) 是为了防止标准差为 0 导致除以 0,这是合理的保护性写法。
缩尾逻辑:
z_trim = MIN(MAX(z_raw, -3.0), 3.0) 就是典型的 ±3σ 截断。
但注意:这里是在 z_raw 空间做缩尾,而 z_raw 的定义依赖上面“cross_mean/std 是否真的是截面统计量”。如果上一步不成立,这里的缩尾在金融意义上就不再是“截面缩尾”。
(3) 再做截面 Z-score 标准化
factor_z = ZSCORE(z_trim)
ZSCORE 在文档中明确写成 截面 z-score 标准化;
即:对于每个交易日:
计算所有股票的 z_trim 的均值和标准差;
做 (z_trim - mean_cs) / std_cs。
这一步是完全合理的:
如果你希望因子最终在截面上均值为 0,标准差为 1,便于和其他因子组合,这一步是非常必要的;
且 ZSCORE 会正确按截面处理,不依赖引擎是否对 TS_MEAN(…, 1) 做特殊处理。
(4) 异常值处理
factor_z = IF(ABS(factor_z) > 1e6, 0, factor_z)
return factor_z
用一个非常大的阈值(1e6)把异常值压成 0,避免极端分母过小导致因子爆炸,是一种简单有效的防御性措施;
正常情况下,截面 z-score 后的值一般在 [-5, 5] 内,超过 10 都已经非常少见,1e6 只是兜底保护,几乎不会伤及正常数据。
- 潜在问题与优化建议
3.1 缩尾那一步可能逻辑不符合文档
关键问题:
从你注释来看,你希望 cross_mean 和 cross_std 是 截面均值与标准差;
但从文档定义来看,TS_MEAN / STDDEV 是典型的时序函数,不会自动变成截面统计;
如果引擎没有做特殊处理,你的代码在这一段实际上是:
cross_mean = mom20
cross_std ≈ 0(或NaN)
z_raw = (mom20 - mom20) / eps ≈ 0
z_trim ≈ 0
然后 ZSCORE(z_trim) 得到的将是一堆 0 或 NaN,因子就失效了。
因此:
这里要么确认你们引擎真的支持“参数=1时按截面处理”,
要么在实现上,直接放弃 TS_MEAN/STDDEV 这一步,而只保留 ZSCORE 的截面标准化。
3.2 设计层面:是否需要两次“类似 z-score”的操作?
当前流程本质上是:
手动 z-score 一次(用 cross_mean / cross_std);
在 z-score 空间做 ±3 截断;
再用 ZSCORE 做一次截面 z-score。
从效果上看:
第一次 z-score + 截断 = 截面 winsorize;
第二次 z-score = 对缩尾结果再标准化。
这是一个合理的“缩尾+标准化”套路,只要第一步真的按截面统计就没问题。
如果你希望:
简化实现;
并且不依赖引擎的特殊行为;
可以考虑把逻辑改为:
直接在 mom20 上用 RANK 或 ZSCORE 做截面处理,然后用 SCALE 或再次 ZSCORE 标准化,这在截面上也能起到类似“压缩极端值”的效果(但这已经是修改代码,而你现在只问分析,这里就不展开具体代码)。
3.3 NaN/缺失值的处理
代码最后只对极大绝对值做了保护:
factor_z = IF(ABS(factor_z) > 1e6, 0, factor_z)
如果在截面统计或时间序列中出现 NaN,这一步不会把 NaN 转成 0;
一般来说:
上市首日、大停牌复牌、价格为 0、量价数据缺失等情况可能产生 NaN;
如果回测框架允许 NaN(通常解释为“无信号、不参与排序”)则没问题;
如果框架不允许 NaN,则需要额外一步,如:用 0 或截面中位数替代 NaN。
你当前的保护对“无限大/极值”是有效的,对 NaN 不处理,这一点要结合你们引擎的默认行为来判断是否需要补充。
- 总结
动量定义:RETURNS(close, 20) 作为 20 日动量是合理且无未来数据的。
缩尾意图:设计上希望通过“截面 z-score + ±3 截断 + 再 z-score”实现稳健的截面动量因子,这个金融逻辑是合理的。
最大的不确定点:
TS_MEAN(mom20, 1) 与 STDDEV(mom20, 1) 是否真的在你们平台中被实现为“截面均值/标准差”;
若没有特殊实现,这一段代码在数学上会退化,因子几乎全为 0 或异常。
防御性处理:对标准差加 eps,对极值用 0 替代,都是实务中常用的防御写法。
如果你后续需要,我可以根据你们引擎的真实行为,帮你把这段逻辑改写成:
明确使用截面函数实现“截面缩尾 + 标准化”;
或者用更简单、计算稳定性更高的实现方式,给出完整可跑的因子类代码。