zpos因果对冲的分析和示例

因果对冲是一种实测有效的执行策略,这里示例一个理想化的zpos因果对冲的代码实现。

包括对冲book来源,因果beta如何估计,对冲如何执行,如何通过逐日执行获得收益。

1 因果对冲

1.1 因果对冲计算

经典的因果对冲执行逻辑流程如下

1)每个交易日 t,用截至 t-1 的 60 日滚动窗口估计zpos book对benchmark的beta,

2)令当日空头比率等于该滞后 beta,然后计算

3)最后以零基准评估其绝对收益、波动、Sharpe 和最大回撤;

1.2 因果对冲代码

这里实现了一个实时、无前视的简化的动态beta 因果对冲(causal hedge)。

每天用截至前一日滚动beta决定当日做空基准的比例,再从策略book收益中扣掉这部分基准暴露。

复制代码
"""Beta-neutralization experiment --- separate factor alpha from market exposure.

Motivation
----------
Every long-only monetization (ew_top50 / rank_all / zpos) of the surviving
skewness signal carries full market beta: max drawdowns run -48%..-68%
because the books ride the underlying index through 2015/2018/2024.  The
market cycle dwarfs the few %/yr of factor alpha, so "excess vs EW" is
dominated by beta mismatch rather than factor P&L (see docs/research_findings.md).

Two separable questions, both labelled explicitly:

  Q1 feature-level (cross-sectional) neutralization
     Before ranking, strip the systematic beta tilt out of the composite:
       z_tilde_i = z_i - (a + b_t * beta_i)   (OLS residual per date)
     with beta_i = 60d trailing CAPM beta of stock i vs the equal-weight
     universe.  Nothing about the ranking mechanics changes; only the score
     fed to it is orthogonalized.  Reported as the zpos book's ex-ante beta
     tilt before/after (should go ~0 by construction) and the resulting
     zpos/rank_all/zls metrics.

  Q2 portfolio-level hedge (market-neutrality)
     Hold the long-only book and short beta_book x benchmark:
       r_hedged_t = r_book_t - beta_hedge_t * r_bm_t
     Two beta_hedge estimators are reported:
       * ex-post  : full-sample regression of the book on the benchmark
                    (a hindsight diagnostic = the frictionless-hedge ceiling)
       * causal   : 60d trailing beta, shifted so day t uses data <= t-1
                    (estimable in real time)
     A hedged book has beta ~ 0, so IR-vs-benchmark is a category error: we
     report annualized return, ann vol, absolute Sharpe and max drawdown.

Conventions (entry-only cost, halt renormalization by absolute weight mass,
last-day gross = 0) are mirrored from BaseEngine / ic_weighted_test.evaluate
and NOT re-derived.  ic_weighted_test.py stays frozen: its JSONs are the
reference set of docs/research_findings.md §3/§5.
"""

import argparse
import json
import math
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

from mfm.combine import combine_factors_icir_weight
from mfm.pipelines.csi300_screen import (
    CSI300ScreenConfig,
    build_rolling_weights,
    load_universe,
    prepare_context,
    validate_and_screen,
)

BETA_WINDOW = 60          # trailing window for per-stock CAPM beta
BETA_MINP = 30
HEDGE_WINDOW = 60         # trailing window for the book's realized beta
HEDGE_MINP = 30

COMPOSITES = {
    "skew": ["skewness"],
    "core3": ["skewness", "vol_20d", "vol_60d"],
}


def compute_market_beta(returns, window=BETA_WINDOW, minp=BETA_MINP):
    """60d trailing CAPM beta of each stock vs the equal-weight universe."""
    r_mkt = returns.mean(axis=1)
    var = r_mkt.rolling(window, min_periods=minp).var()
    cov = returns.rolling(window, min_periods=minp).cov(r_mkt)
    return cov.div(var, axis=0)


def neutralize(z, beta, min_obs=12):
    """Per-date OLS: keep the residual of z on beta (feature-level neutrality)."""
    resid = pd.DataFrame(np.nan, index=z.index, columns=z.columns)
    cols = z.columns
    for t in z.index:
        x = beta.loc[t].reindex(cols)
        y = z.loc[t].reindex(cols)
        m = x.notna() & y.notna()
        nm = int(m.sum())
        if nm < min_obs:
            continue
        xm = x[m].to_numpy(dtype=float)
        ym = y[m].to_numpy(dtype=float)
        A = np.column_stack([np.ones(nm), xm])
        coef, *_ = np.linalg.lstsq(A, ym, rcond=None)
        resid.loc[t, cols[m]] = ym - A @ coef
    return resid


def build_scheme_returns(comp, ret_next, rebal_dates, cfg, scheme):
    """Net daily return series for ONE scheme.

    Mirrors ic_weighted_test.evaluate's inner loop verbatim for `scheme`
    (zpos / rank_all), returning the (gross - cost) net Series so it can be
    hedged at the portfolio level.
    """
    idx = comp.index
    all_cols = ret_next.columns
    w_rows, costs = {}, {}
    prev_support, prev_w = None, None
    for T in rebal_dates:
        if T not in comp.index:
            continue
        w = base.scheme_weights(comp.loc[T], scheme, cfg.n_hold).reindex(all_cols).fillna(0.0)
        w_rows[T] = w
        support = w.index[w != 0.0]
        if prev_support is None:
            notional = float(w.abs().sum())  # full cost on initial entry
        else:
            entered = support.difference(prev_support)
            notional = float(w.loc[entered].abs().sum()) if len(entered) else 0.0
        costs[T] = cfg.cost * notional
        prev_support, prev_w = support, w

    W = pd.DataFrame(w_rows).T.reindex(idx).ffill().fillna(0.0)
    valid = ret_next.notna() & (W != 0.0)
    w_valid = W.where(valid, 0.0)
    mass = w_valid.abs().sum(axis=1)
    numer = (w_valid * ret_next.fillna(0.0)).sum(axis=1)
    gross = (numer / mass.where(mass != 0.0)).fillna(0.0)
    gross.iloc[-1] = 0.0  # engine last-day convention
    cost_s = pd.Series(0.0, index=idx)
    for T, c in costs.items():
        cost_s.loc[T] = c
    return gross - cost_s


def exante_beta_tilt(comp, beta, rebal_dates, cfg, scheme):
    """Weighted-avg 60d beta of the book's target weights (rebalance-time)."""
    tilts = []
    for T in rebal_dates:
        if T not in comp.index:
            continue
        w = base.scheme_weights(comp.loc[T], scheme, cfg.n_hold)
        b = beta.loc[T]
        common = w.index.intersection(b.dropna().index)
        if len(common) == 0:
            continue
        num = float((w.loc[common] * b.loc[common]).sum())
        den = float(w.loc[common].abs().sum())
        if den > 0:
            tilts.append(num / den)
    return float(np.mean(tilts)) if tilts else float("nan")


def realized_beta(ret, bm):
    """Full-sample regression beta of a book on the benchmark (ex-post)."""
    a = ret - ret.mean()
    b = bm - bm.mean()
    denom = float((b * b).sum())
    return float((a * b).sum() / denom) if denom > 0 else 0.0


def rolling_hedge_beta(book, bm, window=HEDGE_WINDOW, minp=HEDGE_MINP):
    """Causal 60d book beta; day t uses data <= t-1 (shifted)."""
    var = bm.rolling(window, min_periods=minp).var()
    cov = book.rolling(window, min_periods=minp).cov(bm)
    return (cov / var).shift(1)


def hedge(book, bm, beta_hedge):
    """r_book - beta_hedge * r_bm (beta_hedge: float or aligned Series)."""
    bm_a = bm.reindex(book.index).fillna(0.0)
    if isinstance(beta_hedge, pd.Series):
        bh = beta_hedge.reindex(book.index).fillna(0.0)
    else:
        bh = beta_hedge
    return book - bh * bm_a


def main():
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("--tag", required=True)
    ap.add_argument("--start", required=True)
    ap.add_argument("--end", required=True)
    ap.add_argument("--instruments", default="data/cn_data/instruments/csi300.txt")
    ap.add_argument("--outdir", default="./output_btn")
    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/4] Loading & validating ({args.tag})...")
    prices, returns, mask, uf = load_universe(cfg)
    screen = validate_and_screen(uf, prices, cfg)
    print("[2/4] Rolling weights (shared OOS window)...")
    roll_icir, roll_blend, oos_idx = build_rolling_weights(screen, prices.index, cfg)
    ctx = prepare_context(prices, returns, mask, uf, roll_icir, roll_blend, oos_idx, cfg)

    print("[3/4] Market beta (60d trailing vs EW universe)...")
    beta_all = compute_market_beta(returns).loc[oos_idx]
    ret_next = returns.shift(-1).loc[oos_idx]
    bm = ctx.benchmark
    zero = pd.Series(0.0, index=bm.index)

    out = {
        "tag": args.tag,
        "oos_window": [str(oos_idx[0].date()), str(oos_idx[-1].date())],
        "oos_days": int(len(oos_idx)),
        "beta_window": BETA_WINDOW,
        "hedge_window": HEDGE_WINDOW,
        "composites": {},
    }

    print("[4/4] Neutralization & hedge...")
    for cname, factors in COMPOSITES.items():
        w = {
            f: base.FIXED_SIGNS.get(f, 1.0 if screen.icir[f] >= 0 else -1.0)
            for f in factors
        }
        comp_raw = combine_factors_icir_weight(ctx.norm_all, w).where(ctx.mask_oos)
        comp_neu = neutralize(comp_raw, beta_all).where(ctx.mask_oos)

        cell = {"weights": w, "schemes_raw": None, "schemes_neutral": None, "hedge": {}}
        cell["schemes_raw"] = base.evaluate(comp_raw, ret_next, ctx.rebal_dates, bm, cfg)
        cell["schemes_neutral"] = base.evaluate(comp_neu, ret_next, ctx.rebal_dates, bm, cfg)

        tilt_raw = exante_beta_tilt(comp_raw, beta_all, ctx.rebal_dates, cfg, "zpos")
        tilt_neu = exante_beta_tilt(comp_neu, beta_all, ctx.rebal_dates, cfg, "zpos")
        cell["zpos_exante_beta_tilt"] = {"raw": tilt_raw, "neutral": tilt_neu}

        book_raw = build_scheme_returns(comp_raw, ret_next, ctx.rebal_dates, cfg, "zpos")
        book_neu = build_scheme_returns(comp_neu, ret_next, ctx.rebal_dates, cfg, "zpos")
        for bk_name, book in (("raw", book_raw), ("neutral", book_neu)):
            bep = realized_beta(book, bm)
            broll = rolling_hedge_beta(book, bm)
            cell["hedge"][bk_name] = {
                "book_beta_expost": bep,
                "unhedged": base.calc_metrics(book, zero),
                "hedged_expost": base.calc_metrics(hedge(book, bm, bep), zero),
                "hedged_causal": base.calc_metrics(hedge(book, bm, broll), zero),
            }

        out["composites"][cname] = cell

        wtxt = ", ".join(f"{k2}:{v2:+.0f}" for k2, v2 in w.items())
        print(f"\n=== [{cname}] w=({wtxt})  zpos ex-ante beta tilt: "
              f"raw={tilt_raw:+.2f}  neutral={tilt_neu:+.2f}")
        print("  -- feature-level (Q1): raw vs neutralized composite --")
        for name in ("ew_top50", "rank_all", "zpos", "zls"):
            mr = cell["schemes_raw"][name]
            mn = cell["schemes_neutral"][name]
            print(f"  {name:9s} raw IR={mr['information_ratio']:+.2f} "
                  f"ann={mr['annual_return']:+.2%} maxDD={mr['max_drawdown']:.1%}"
                  f"  |  neu IR={mn['information_ratio']:+.2f} "
                  f"ann={mn['annual_return']:+.2%} maxDD={mn['max_drawdown']:.1%}")
        print("  -- portfolio hedge (Q2): zpos unhedged vs ex-post vs causal --")
        for bk_name in ("raw", "neutral"):
            h = cell["hedge"][bk_name]
            u, ex, cx = h["unhedged"], h["hedged_expost"], h["hedged_causal"]
            vol_u = book_raw.std() * math.sqrt(252) if bk_name == "raw" \
                else book_neu.std() * math.sqrt(252)
            print(f"  [{bk_name}] book beta(ex-post)={h['book_beta_expost']:+.2f} "
                  f"unhedged vol={vol_u:.1%}")
            print(f"     unhedged    ann={u['annual_return']:+.2%} "
                  f"sharpe={u['sharpe_ratio']:+.2f} maxDD={u['max_drawdown']:.1%}")
            print(f"     hedged_ex   ann={ex['annual_return']:+.2%} "
                  f"sharpe={ex['sharpe_ratio']:+.2f} maxDD={ex['max_drawdown']:.1%}")
            print(f"     hedged_caus ann={cx['annual_return']:+.2%} "
                  f"sharpe={cx['sharpe_ratio']:+.2f} maxDD={cx['max_drawdown']:.1%}")

    path = os.path.join(args.outdir, f"btn_{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 对冲链路梳理

这里基于以上示例代码,按执行链路进行深入详细的梳理。

2.1 因果对冲位置

这里因果对冲实验分两条线:

1)Q1特征层中性化

neutralize(comp_raw, beta_all)

在排序前把composite 对股票beta做横截面OLS,取残差。

改变的是选股分数。

2)Q2 组合层对冲

hedge(book, bm, beta_hedge)`

不改变选股,只改变收益序列:

其中causal版本用滚动、滞后一期的 beta。

Q2只对zpos方案生成的book做对冲,不是对ew_top50 / rank_all / zls全部做。

2.2 被对冲的book怎么来

这里示例被对冲的book的计算过程,具体为build_scheme_returns。

build_scheme_returns(comp, ret_next, rebal_dates, cfg, "zpos")

负责生成zpos组合的日净收益,细节如下:

  1. 每个调仓日T,用base.scheme_weights(comp.locT, "zpos", n_hold)生成目标权重。

  2. 记录权重,计算 entry-only 成本:

  • 初始建仓:全部绝对权重;

  • 后续调仓:只对新增股票entered的绝对权重收费。

  1. 将调仓权重ffill到日频,得到W。

  2. 用 ret_next计算每日 gross:

并对无效收益、零权重做质量归一化。

  1. 扣成本,最后一日 gross.iloc-1 = 0.0,得到 book。

所以book_raw和book_neu分别是:

  • 原始 composite 的zpos净收益;

  • 特征中性化后 composite 的zpos净收益。

2.3 因果beta的估计

因果beta的估计的实现rolling_hedge_beta

def rolling_hedge_beta(book, bm, window=60, minp=30):

var = bm.rolling(window, min_periods=minp).var()

cov = book.rolling(window, min_periods=minp).cov(bm)

return (cov / var).shift(1)

rolling_hedge_beta实现逻辑如下

  1. 对基准 bm计算 60 日滚动方差。

  2. 对 book和bm计算 60 日滚动协方差。

  3. 得到滚动 beta:

  1. .shift(1):

即t日使用的对冲比率只基于t-1及之前的数据,避免用到t日收益,防止前视偏差。

rolling_hedge_beta其他参数说明如下

1)BETA_WINDOW = 60,滚动窗口 60 天。

2)BETA_MINP = 30,至少 30 个观测才计算。

因此前约30个交易日beta为NaN,后续fillna(0.0)后会变成 0,等价于早期不对冲。

2.4 对冲执行

对冲执行函数是hedge,具体如下:

def hedge(book, bm, beta_hedge):

bm_a = bm.reindex(book.index).fillna(0.0)

if isinstance(beta_hedge, pd.Series):

bh = beta_hedge.reindex(book.index).fillna(0.0)

else:

bh = beta_hedge

return book - bh * bm_a

hedge逐日执行如下计算

实现细节如下

  • bm对齐到book.index,缺失基准收益按 0 处理。

  • 如果beta_hedge是 Series,则按日对齐;缺失 beta 按 0 处理。

  • 如果beta_hedge是 float,则是常数对冲,例如全样本 ex-post beta。

2.5 main实际调用链

main示例了因果对冲的实际对以上函数实现的调用链条。

1)调用build_scheme_returns

对每个 composite,例如skew或core3:

book_raw = build_scheme_returns(comp_raw, ret_next, ctx.rebal_dates, cfg, "zpos")

book_neu = build_scheme_returns(comp_neu, ret_next, ctx.rebal_dates, cfg, "zpos")

2)计算beta

然后对raw和neutral两个book 分别做:

bep = realized_beta(book, bm) # 全样本 ex-post beta

broll = rolling_hedge_beta(book, bm) # 因果滚动 beta

其中:

realized_beta(book, bm)

用全样本回归得到book对 benchmark 的 beta:

这是上帝视角,用了全样本信息,不能实盘,只作为摩擦无成本对冲上限诊断。

rolling_hedge_beta(book, bm)

得到逐日因果 beta,用于实盘可执行版本。

3)最后计算三组指标

这里为计算三种不同对冲实现的年化指标。

"unhedged": base.calc_metrics(book, zero),

"hedged_expost": base.calc_metrics(hedge(book, bm, bep), zero),

"hedged_causal": base.calc_metrics(hedge(book, bm, broll), zero),

注意 zero = pd.Series(0.0, index=bm.index)。

所以calc_metrics(..., zero)是以 0 为基准,报告的是绝对表现:

比如年化收益、年化波动、绝对 Sharpe、最大回撤等。

对冲后 book beta 接近 0,因此不能再按相对基准 IR解释,

代码注释里也明确说IR-vs-benchmark是类别错误。

2.6 逐日执行时序

因果对冲的核心是,今日对冲比率只来自昨日及更早信息。

以 t 日为例,执行时序说明如下

1)t-1 收盘后:

用截至 t-1的过去60日book和bm收益,计算滚动 beta:

2)t日交易前/持有期开始:

设置对冲比率:

3)t 日持有期间:

持有zpos股票组合,同时做空 倍benchmark。

4)t日收益实现:

5)t 日收盘后:

更新滚动窗口,计算,供 t+1 日使用。

3 与ex-post的区别联系

3.1 区别

这里进一步对比hedged_expost,示例其作用。

1)hedged_expost

hedged_expost是全样本回归beta,不可以实盘,因为有前视,用于理想上限/诊断。

2)hedged_causal

hedged_causal,bela来源于60 日滚动 beta,并shift(1),可实盘,实时可估,是实际可执行版。

3)unhedged

unhedged,其beta不对冲,可实盘,是原始zpos 表现。

3.2 联系

全样本 ex-post beta只作为前视诊断上限。

比较hedged_expost和hedged_causal,可以判断出如下重要信息。

1)滚动 beta 是否稳定;

2)实时对冲相对理想对冲损失多少;

3)市场暴露是否被有效抵消。

4 注意事项

4.1 只对zpos做Q2对冲

build_scheme_returns(..., zpos)写死了。

ew_top50 / rank_all / zls只出现在Q1的 raw vs neutral 对比里。

4.2 基准口径可能不一致

Q1 的股票 beta 是compute_market_beta(returns),即相对等权 universe 的 beta。

Q2 对冲用的是bm = ctx.benchmark。

如果ctx.benchmark不是同一个等权 universe,那么特征中性化 beta和组合对冲beta口径不同。

4.3早期beta缺失被填0

rolling_hedge_beta初期 NaN,hedge中fillna(0.0),意味着前约 30 天不 hedge。

若严格评估,可以考虑从有 beta 的日期开始统计。

4.4 无对冲交易成本

代码只扣股票book的entry-only 成本,没有扣:

  • 做空 benchmark 的借券/期货/融券成本;

  • 保证金占用;

  • 基差、展期、冲击成本;

  • 对冲比率调整带来的换手成本。

所以hedged_causal是理想化实时对冲。

4.5 calc_metrics(..., zero)口径

传入零基准后,得到的不是相对 benchmark 的 IR,而是绝对收益序列的 Sharpe 类指标。

所以,打印阶段进一步补充计算vol_u = book.std()*sqrt(252)

reference


多因子Beta对冲工具的管理分析

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

如何生成日度策略收益序列

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

滚动CAPM贝塔的计算示例和分析

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

相关推荐
Shan12051 小时前
经典算法题学习:跳跃游戏IV(一)
算法
AIGCmagic社区1 小时前
灵巧手VLA真机均分71%,北大DeCAL用接触门控接入触觉
人工智能·算法·aigc·ai多模态
迷途之人不知返1 小时前
算法系列4:前缀和
算法
吠品2 小时前
Python 写入 Excel 的两种主流方案实际用法总结
c语言·开发语言·算法
Zane19942 小时前
归并排序和堆排序都能保证O(nlogn),为什么谁也没法把稳定和原地两个优点占全
算法·排序算法
天天喝旺仔2 小时前
Go 泛型实战:从类型参数、约束到可复用泛型容器与函数
数据结构·算法·容器·go
可爱的小小小狼2 小时前
【无标题】
java·算法
和裕3 小时前
平口开槽箱 vs 飞机盒 vs 扣底盒:自动化、展示效果与成本核心区别
大数据·运维·网络·人工智能·算法·自动化
AgentMaster3 小时前
从售前到售后全链路覆盖:智能客服在企业 5 大场景的落地实践与工具选型
大数据·人工智能·算法