股票基础动量因子分析(Week 5)
  bluewaxberry 11天前 89 0

用2025年的数据, 写一个基础动量因子分析框架,用于参加因子大赛

策略准备,工作流创建

  • 工作流;01.jpg
  • 仿真结果;02.jpg
  • 03.jpg
  • 04.jpg
  • 05.jpg

1.2 策略分析

################################## 策略分析和解读 #######################################

  1. 因子金融含义与整体设计

这个因子 Momentum20DTrimmedZscoreFactor 的核心思想:

用 20日累计收益率 度量动量(momentum);
对截面极端值做 缩尾(winsorize) 处理,降低极端值对截面标准化的影响;
对缩尾后的值做 截面 Z-score 标准化,便于与其他因子组合和比较;
对异常值(极大极小)做保护性处理,防止数值爆炸导致回测报错。

这是一个比较标准、且相对稳健的截面动量因子设计框架:

使用过去 20 日收益率捕捉短期趋势;

用缩尾 + 标准化提高鲁棒性和可组合性;

避免使用未来数据,符合因子回测的基本规范。

从金融逻辑上看,这个因子要表达的就是:

在过去 20 日价格上涨多且不太极端的股票,因子值更高;跌得多的股票因子值更低。

  1. 代码逐行逻辑分析

(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 只是兜底保护,几乎不会伤及正常数据。

  1. 潜在问题与优化建议

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 不处理,这一点要结合你们引擎的默认行为来判断是否需要补充。

  1. 总结

动量定义:RETURNS(close, 20) 作为 20 日动量是合理且无未来数据的。
缩尾意图:设计上希望通过“截面 z-score + ±3 截断 + 再 z-score”实现稳健的截面动量因子,这个金融逻辑是合理的。
最大的不确定点:

TS_MEAN(mom20, 1) 与 STDDEV(mom20, 1) 是否真的在你们平台中被实现为“截面均值/标准差”;

若没有特殊实现,这一段代码在数学上会退化,因子几乎全为 0 或异常。

防御性处理:对标准差加 eps,对极值用 0 替代,都是实务中常用的防御写法。

如果你后续需要,我可以根据你们引擎的真实行为,帮你把这段逻辑改写成:

明确使用截面函数实现“截面缩尾 + 标准化”;

或者用更简单、计算稳定性更高的实现方式,给出完整可跑的因子类代码。

最后一次编辑于 11天前 0

暂无评论

推荐阅读