IVOL与偏度因子的对比测量分析

特质波动率因子和偏度因子都是量化市场经得起测试和验证的因子。

假设我们已经对偏度因子的收益进行了深入的分析和测试,这里进一步验证

在已有偏度因子情况下,特质波动率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 代码总体流程

脚本流程示例如下

  1. 加载 CSI300 股票池的价格、收益、mask、已有因子 `uf`。

  2. 构造 `idio_vol` 因子:CAPM 市场模型残差波动率,并取负号。

  3. 对 `skewness` 和 `idio_vol` 做"四透镜"验证:

  • 全窗口;

  • 2023 年后近期窗口;

  • 样本内 `<= freeze`;

  • 样本外 `>= oos_start`。

  1. 用样本内 ICIR 决定因子方向,冻结符号。

  2. 在样本外构建 `zpos` 组合,并用 60 日因果滚动 beta 做市场中性对冲。

  3. 按预声明的 P1 规则,把 IVOL 与 skewness 头对头比较:

  • 成本透镜:|ICIR|、|rankICIR|;

  • 金钱透镜:符号调整后的 Q5-Q1 spread Sharpe、对冲后 zpos Sharpe;

  • 近期是否符号翻转。

  1. 输出 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

因子截面标准化的探索分析和代码示例

https://blog.csdn.net/liliang199/article/details/164367750

相关推荐
threerocks3 小时前
Jev 入门第一课
算法
西柚研究生1234564 小时前
论文分析17:YOLOv11_UAVNet:无人机航拍图像专用目标检测算法
人工智能·python·深度学习·算法·目标检测
hetao17338375 小时前
2026-09-17 hetao1733837 的刷题记录
c++·算法
午彦琳6 小时前
2026.9.17
数据结构·算法·leetcode
木井巳6 小时前
【记忆化搜索】不同路径
java·算法·leetcode·深度优先·剪枝·推荐算法
怕浪猫7 小时前
从 Windows 换到 Mac 三个月,我真香了
算法·面试·架构
aichitang20248 小时前
前端小skill
前端·人工智能·算法·ai·前端框架
All for pursuit.9 小时前
【链表-9】146.LRU缓存
数据结构·c++·算法·leetcode
木子算法9 小时前
测出来的值会抖:约束和目标带噪声时,「可行」和「更好」该怎么判
人工智能·算法·目标跟踪