特质波动率因子和偏度因子都是量化市场经得起测试和验证的因子。
假设我们已经对偏度因子的收益进行了深入的分析和测试,这里进一步验证
在已有偏度因子情况下,特质波动率IVOL是否在统计预测力、可交易收益、近期稳定性上都更优
1 对比逻辑探索
从金融逻辑角度分析
-
IVOL异象和偏度异象都源于彩票偏好、套利限制、卖空约束;
-
高IVOL股票往往高偏度,二者可能高度相关;
-
只有IVOL在头对头中同时赢下成本透镜和金钱透镜,且近期没有符号翻转,才值得采纳。
在方法的角度上,
-
用 CAPM 残差波动率定义 IVOL;
-
用四透镜和冻结样本外验证稳健性;
-
用 zpos + 因果滚动 beta 对冲检验纯 alpha;
-
用 P1 预声明规则避免事后选择。
如果最终adopt=YES,说明IVOL提供了超越偏度的增量信息;
如果NO,则 IVOL 可能只是偏度的代理,或者近期已失效。
2 对比测试框架
以下代码是一个规范、预声明的因子增量测试框架。它要检验:
在已有偏度因子情况下,特质波动率IVOL是否在统计预测力、可交易收益、近期稳定性上都更优
"""P6 --- idiosyncratic volatility (IVOL) vs the incumbent skewness.
IVOL = std of the CAPM (market-model) residual (60d trailing, min 30 obs),
inverted: high IVOL -> low factor (lottery-like), mirroring -skew convention.
Run the SAME four-lens (full window + 2023+ flip) and the SAME frozen-rule
walk-forward (sign decided <= freeze, OOS zpos + 60d-causal hedge), then the
pre-declared P1 head-to-head vs skewness (cost lens: |ICIR|/|rankICIR|; money
lens: sign-adjusted quintile-spread Sharpe + hedged zpos Sharpe; no 2023+ flip).
FACTOR_REGISTRY is NOT modified.
"""
import argparse
import json
import os
import sys
import numpy as np
import pandas as pd
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ic_weighted_test as base
import beta_neutral_test as btn
from mfm.combine import combine_factors_icir_weight
from mfm.factors import cross_sectional_normalize
from mfm.pipelines.csi300_screen import CSI300ScreenConfig, load_universe
from mfm.validation import validate_factors
N_HOLD = {"skewness", "idio_vol"}
def idio_volatility_factor(prices, window=60, min_periods=30):
"""Std of CAPM-market residuals, inverted (higher = lower IVOL).
Residual = logret_i - beta_i * (panel-EW log return); beta_i = 60d trailing
beta vs the panel-EW market proxy (same market proxy as idio_skew_test.py).
"""
ret = np.log(prices / prices.shift(1))
r_mkt = ret.mean(axis=1)
var_m = r_mkt.rolling(window, min_periods=min_periods).var()
beta = ret.rolling(window, min_periods=min_periods).cov(r_mkt).div(var_m, axis=0)
resid = ret - beta.multiply(r_mkt, axis=0)
ivol = resid.rolling(window, min_periods=min_periods).std()
return -ivol
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--tag", required=True)
ap.add_argument("--instruments", default="data/cn_data/instruments/csi300.txt")
ap.add_argument("--start", required=True)
ap.add_argument("--end", required=True)
ap.add_argument("--freeze", default="2022-12-31")
ap.add_argument("--oos-start", default="2023-01-01")
ap.add_argument("--outdir", default="./output_idiov")
args = ap.parse_args()
os.makedirs(args.outdir, exist_ok=True)
cfg = CSI300ScreenConfig(
start=args.start, end=args.end,
instruments_path=args.instruments, out_dir=args.outdir,
)
print(f"[1/5] loading ({args.tag})...")
prices, returns, mask, uf = load_universe(cfg)
dates = prices.index
print("[2/5] computing idio-vol...")
ivol = idio_volatility_factor(prices)
facs = {"skewness": uf["skewness"], "idio_vol": ivol.where(mask)}
print("[3/5] four-lens (full window + 2023+ flip)...")
val = validate_factors(facs, prices, horizon=cfg.horizon, n_groups=5)
hold = dates[dates >= "2023-01-01"]
val_h = validate_factors(
{n: facs[n].loc[hold] for n in facs}, prices.loc[hold],
horizon=cfg.horizon, n_groups=5,
)
four = {}
for n in N_HOLD:
s = val["ic_summary"][n]
r = val["rank_ic_summary"][n]
sp = val["spread_summary"][n]
h = val_h["ic_summary"][n]
four[n] = {
"icir": float(s["icir"]), "t_stat": float(s["t_stat"]),
"rank_icir": float(r["icir"]),
"spread_annualized": float(sp["annualized"]),
"spread_sharpe": float(sp["sharpe"]),
"spread_win_rate": float(sp["win_rate"]),
"recent_icir": float(h["icir"]),
"recent_sign_flip": bool(np.sign(s["icir"]) != np.sign(h["icir"])),
}
print(f" {n:10s} ICIR={four[n]['icir']:+.4f} t={four[n]['t_stat']:+.2f} "
f"rankICIR={four[n]['rank_icir']:+.4f} | spread={four[n]['spread_annualized']:+.2%} "
f"sharpe={four[n]['spread_sharpe']:+.2f} win={four[n]['spread_win_rate']:.1%} "
f"| 2023+ {four[n]['recent_icir']:+.4f} flip={four[n]['recent_sign_flip']}")
print(f"[4/5] in-sample sign (<= {args.freeze}) + frozen OOS monetization...")
cut = dates[dates <= args.freeze]
val_is = validate_factors(
{n: facs[n].loc[cut] for n in facs}, prices.loc[cut],
horizon=cfg.horizon, n_groups=5,
)
signs = {n: 1.0 if val_is["ic_summary"][n]["icir"] >= 0 else -1.0 for n in N_HOLD}
oos_idx = dates[dates >= args.oos_start]
bm = returns.where(mask).mean(axis=1).shift(-1).loc[oos_idx]
ret_next = returns.shift(-1).loc[oos_idx]
rebal = oos_idx[:: cfg.rebal]
zero = pd.Series(0.0, index=bm.index)
money = {}
for n in N_HOLD:
norm = cross_sectional_normalize(facs[n].loc[oos_idx])
comp = combine_factors_icir_weight({n: norm}, {n: signs[n]}).where(mask.loc[oos_idx])
book = btn.build_scheme_returns(comp, ret_next, rebal, cfg, "zpos")
bep = btn.realized_beta(book, bm)
broll = btn.rolling_hedge_beta(book, bm)
hc = base.calc_metrics(btn.hedge(book, bm, broll), zero)
money[n] = {
"sign": signs[n], "book_beta_expost": bep,
"hedged_causal_ann": float(hc["annual_return"]),
"hedged_causal_sharpe": float(hc["sharpe_ratio"]),
"hedged_causal_maxdd": float(hc["max_drawdown"]),
}
print(f" [{n}] frozen sign {signs[n]:+.0f} "
f"hedged_caus ann={hc['annual_return']:+.2%} sharpe={hc['sharpe_ratio']:+.2f} "
f"maxDD={hc['max_drawdown']:.1%}")
print("[5/5] head-to-head vs skewness (P1 rule)...")
sk = four["skewness"]
f = four["idio_vol"]
fm = money["idio_vol"]
skm = money["skewness"]
cost_win = abs(f["icir"]) > abs(sk["icir"]) and abs(f["rank_icir"]) > abs(sk["rank_icir"])
spread_adj = f["spread_sharpe"] * fm["sign"] # sign-adjust raw Q5-Q1 spread
money_win = spread_adj > sk["spread_sharpe"] and fm["hedged_causal_sharpe"] > skm["hedged_causal_sharpe"]
adopt = cost_win and money_win and not f["recent_sign_flip"]
print(f" cost_win={cost_win} money_win={money_win} flip={f['recent_sign_flip']} "
f"-> adopt={'YES' if adopt else 'NO'}")
out = {
"tag": args.tag, "freeze": args.freeze,
"oos_window": [str(oos_idx[0].date()), str(oos_idx[-1].date())],
"oos_days": int(len(oos_idx)),
"four_lens": four, "money": money,
"verdict": {"cost_win": cost_win, "money_win": money_win,
"flip": f["recent_sign_flip"], "adopt": bool(adopt)},
}
path = os.path.join(args.outdir, f"idio_vol_{args.tag}.json")
with open(path, "w") as fh:
json.dump(out, fh, indent=1, ensure_ascii=False)
print(f"\nSaved -> {path}")
if __name__ == "__main__":
main()
这是一份研究性、预声明的头对头测试脚本。
下面依次结合代码简写后的伪代码过程,梳理代码逻辑。
2.1 代码总体流程
脚本流程示例如下
加载 CSI300 股票池的价格、收益、mask、已有因子 `uf`。
构造 `idio_vol` 因子:CAPM 市场模型残差波动率,并取负号。
对 `skewness` 和 `idio_vol` 做"四透镜"验证:
全窗口;
2023 年后近期窗口;
样本内 `<= freeze`;
样本外 `>= oos_start`。
用样本内 ICIR 决定因子方向,冻结符号。
在样本外构建 `zpos` 组合,并用 60 日因果滚动 beta 做市场中性对冲。
按预声明的 P1 规则,把 IVOL 与 skewness 头对头比较:
成本透镜:|ICIR|、|rankICIR|;
金钱透镜:符号调整后的 Q5-Q1 spread Sharpe、对冲后 zpos Sharpe;
近期是否符号翻转。
- 输出 JSON 结论:`adopt=YES/NO`。
2.2 因子构造
skewness因子假设已知,这里需要计算IVOL因子,即idio_volatility_factor。
对应代码中"2/5 computing idio-vol..."部分。
对应市场模型为:
其中:
-
:个股对数收益;
-
:市场代理收益,这里是横截面等权平均对数收益;
-:60 日滚动 beta;
-:CAPM 残差;
- IVOL:残差滚动标准差,窗口 60 日,最少 30 个观测。
最后返回 -ivol,即:
-
高 IVOL → 因子值低;
-
低 IVOL → 因子值高。
这与-skewness的约定一致:
高偏度股票像彩票,因子值低;高 IVOL 股票也像彩票,因子值低。
这样两个因子在方向上可比较。
这里不做进一步分析,skewness的细节见如下链接
https://blog.csdn.net/liliang199/article/details/165755208
2.3 四透镜验证
然后是四透镜验证,伪代码过程如下,对应代码部分如下
"3/5 four-lens (full window + 2023+ flip)..."
val = validate_factors(facs, prices, horizon=cfg.horizon, n_groups=5)
hold = datesdates \>= "2023-01-01"
val_h = validate_factors({...}, prices.lochold, ...)
对每个因子计算:
-
ICIR:IC 均值 / IC 标准差;
-
t_stat:IC 显著性;
-
rank_icir:秩 IC 的 ICIR;
-
spread_annualized:Q5-Q1 多空组合年化收益;
-
spread_sharpe:多空组合 Sharpe;
-
spread_win_rate:胜率;
-
recent_icir:2023 年后 ICIR;
-
recent_sign_flip:全窗口 ICIR 与近期 ICIR 符号是否相反。
这里的关键是,不只看看全样本,还看近期是否失效或反转。
2.4 冻结符号与样本外货币化
然后是冻结符号与样本外货币化,对应如下代码片段
"4/5 in-sample sign (<= {args.freeze}) + frozen OOS monetization..."
1)冻结符号
cut = datesdates \<= args.freeze
val_is = validate_factors(...)
signs = {n: 1.0 if val_is"ic_summary"n"icir" >= 0 else -1.0 for n in N_HOLD}
冻结规则:
-
只用 <= freeze的样本内数据决定因子方向;
-
如果样本内 ICIR 为正,则 sign=+1;
-
如果为负,则 sign=-1;
-
样本外不再调整符号。
这是为了避免数据窥探和过拟合。
2)样本外组合
对应代码中部分 "4/4] Monetization (zpos + causal hedge)..."
norm = cross_sectional_normalize(facsn.locoos_idx)
comp = combine_factors_icir_weight({n: norm}, {n: signsn}).where(mask.locoos_idx)
book = btn.build_scheme_returns(comp, ret_next, rebal, cfg, "zpos")
bep = btn.realized_beta(book, bm)
broll = btn.rolling_hedge_beta(book, bm)
hc = base.calc_metrics(btn.hedge(book, bm, broll), zero)
含义:
-
截面标准化:把因子转成 z-score;
-
combine_factors_icir_weight:单因子时,权重基本是符号方向;
实现参考https://blog.csdn.net/liliang199/article/details/164372510
-
zpos:用 z-score 构建多空组合;
-
realized_beta:事后看组合对市场的 beta 暴露;
-
rolling_hedge_beta:滚动估计 beta,用于因果对冲;
-
hedge:做空 beta 倍市场,剥离市场风险;
realized_beta、hedge实现参考 https://blog.csdn.net/liliang199/article/details/165348356
- 最后计算对冲后的年化收益、Sharpe、最大回撤。
核心思想是,因子收益可能只是市场 beta 的伪装,必须做市场中性后看纯 alpha。
2.5 头对头判定
头对头判定,对应代码中5/5 head-to-head vs skewness (P1 rule)...。
cost_win = abs(f"icir") > abs(sk"icir") and abs(f"rank_icir") > abs(sk"rank_icir")
spread_adj = f"spread_sharpe" * fm"sign"
money_win = spread_adj > sk"spread_sharpe" and fm"hedged_causal_sharpe" > skm"hedged_causal_sharpe"
adopt = cost_win and money_win and not f"recent_sign_flip"
头对头判定判定逻辑如下
1)成本透镜:IVOL 的 |ICIR| 和 |rankICIR| 都要大于 skewness;
2)金钱透镜:IVOL 的符号调整后 spread Sharpe 要大于 skewness 的 spread Sharpe,且对冲后 Sharpe 也要大于 skewness;
3)近期不能有符号翻转;
4)三者同时满足才 adopt=YES。
这体现了一个稳健的因子采纳原则:统计预测力、可交易收益、近期稳定性,三者缺一不可。
3 相关现象摘要
3.1 IVOL 异象的解释
主要解释包括:
1)彩票偏好
投资者偏好高偏度、高波动、低价格的"彩票型"股票,导致这类股票被高估,未来收益低。
2)有限关注
散户更关注高波动、高换手、新闻多的股票,推高价格。
3)套利限制
高 IVOL 股票往往难套利,卖空成本高,错误定价持续。
4)卖空限制
A 股尤其明显,高 IVOL 股票可能被高估但难以做空。
5)未充分分散化
投资者持有集中组合,要求更高收益,但实证方向相反。
常见做法是做多低 IVOL、做空高 IVOL。代码中-ivol正是这个方向:高 IVOL → 低因子值。
3.2 偏度与彩票偏好
偏度衡量收益分布的不对称性:
-
正偏度:少数大涨,多数小跌,像彩票;
-
负偏度:少数大跌,多数小涨。
投资者偏好正偏度,愿意为"彩票型"股票支付溢价,导致正偏度股票未来收益低。
因此常见因子是 -skewness:做多低偏度,做空高偏度。
IVOL 与偏度高度相关:高 IVOL 股票往往同时具有正偏度。
3.3 因子验证指标
-
IC:因子值与下期收益的截面相关。IC 越高,预测力越强。
-
ICIR:IC 均值 / IC 标准差。衡量预测稳定性,比单看 IC 更可靠。
-
Rank IC:用秩相关代替 Pearson 相关,对异常值更稳健。
-
Q5-Q1 spread:按因子分 5 组,做多第 5 组、做空第 1 组。年化收益和 Sharpe 衡量可交易性。
-
胜率:多空组合正收益的时间比例。
-
t 统计量:IC 均值是否显著异于 0。
这些指标共同回答:因子是否稳定、显著、可交易。
3.4 组合构建与 beta 中性
-
截面标准化:把因子转成 z-score,使不同股票可比。
-
ICIR 加权:多因子组合中,按历史 ICIR 分配权重。这里单因子,权重就是符号。
-
zpos 组合:用因子 z 值作为权重,做多高因子值、做空低因子值。
-
beta 中性:组合可能暴露市场 beta,必须估计并做空 beta 倍市场,剥离市场风险。
-
因果滚动 beta:只用历史数据估计 beta,避免前视偏差。
-
对冲后 Sharpe:衡量纯 alpha 的风险调整收益。
这一步非常关键,很多因子多空收益看似很高,其实只是承担了市场 beta。
3.5 冻结walk-forward与预声明
-
冻结符号:在 `freeze` 前决定因子方向,之后不再调整。避免"事后知道方向"的数据窥探。
-
样本外验证:oos_start之后的数据完全不参与符号决策。
-
四透镜:全窗口、近期、样本内、样本外,检查稳健性。
-
P1 头对头:预声明成本透镜和金钱透镜,避免事后挑选有利指标。
-
近期符号翻转:如果近期 ICIR 与全窗口相反,说明因子可能失效,不能采纳。
这是非常规范的因子研究流程,接近机构中的"因子动物园"增量检验。
4 补充代码
validate_factors的代码示例如下所示。
validate_factors定义了一个批量因子验证的调度函数 validate_factors,
对传入的每个因子,统一计算 IC、RankIC、分组组合收益、多空 spread 和换手率,
最后以嵌套字典返回。
import numpy as np
import pandas as pd
from .ic import compute_forward_returns, compute_ic_series, compute_ic_summary
from .portfolios import compute_spread_summary, factor_portfolio_returns, factor_turnover
def validate_factors(
factors: dict[str, pd.DataFrame],
prices: pd.DataFrame,
horizon: int = 20,
n_groups: int = 5,
) -> dict:
"""
Run complete factor validation for all factors.
Returns
-------
Dict with:
ic_summary : dict of factor_name -> IC summary stats
rank_ic_summary : dict of factor_name -> RankIC summary stats
portfolio_results: dict of factor_name -> portfolio analysis
spread_summary : dict of factor_name -> spread stats
turnover : dict of factor_name -> avg turnover
"""
forward_returns = compute_forward_returns(prices, horizon=horizon)
results: dict = {
"ic_summary": {},
"rank_ic_summary": {},
"portfolio_results": {},
"spread_summary": {},
"turnover": {},
}
for name, factor_df in factors.items():
print(f" Validating factor: {name}...")
# Clean factor
factor_clean = factor_df.replace([np.inf, -np.inf], np.nan)
# IC
ic_series = compute_ic_series(factor_clean, forward_returns, method="pearson")
results["ic_summary"][name] = compute_ic_summary(ic_series)
results["ic_summary"][name]["ic_series"] = ic_series
# RankIC
rank_ic_series = compute_ic_series(factor_clean, forward_returns, method="spearman")
results["rank_ic_summary"][name] = compute_ic_summary(rank_ic_series)
results["rank_ic_summary"][name]["ic_series"] = rank_ic_series
# Portfolio
port_results = factor_portfolio_returns(
factor_clean, prices, forward_returns, n_groups=n_groups
)
results["portfolio_results"][name] = port_results
results["spread_summary"][name] = compute_spread_summary(
port_results["spread"], horizon=horizon
)
# Turnover
turnover_series = factor_turnover(factor_clean, n_groups=n_groups)
results["turnover"][name] = turnover_series.mean()
return results
reference
特质波动率因子IVOL的分析和示例
https://blog.csdn.net/liliang199/article/details/165755208
skewness收益偏度取负因子背后逻辑的探索
https://blog.csdn.net/liliang199/article/details/164087831
多因子静态综合-因子ICIR权重加权计算的过程分析
https://blog.csdn.net/liliang199/article/details/164372510
多因子Beta对冲工具的管理分析
https://blog.csdn.net/liliang199/article/details/165348356
如何生成日度策略收益序列
https://blog.csdn.net/liliang199/article/details/165328624
因子截面标准化的探索分析和代码示例