量化策略绩效归因终极指南:用 Python 给投资组合做"体检",3 步定位收益来源(完整代码)

7 阅读1分钟

免责声明:本文所有代码仅供学习参考,不构成任何投资建议。量化交易有风险,入市需谨慎。

一、为什么你的策略赚钱了却不知道咋赚的?

想象一下这个场景:

你的量化策略运行了一年,回测显示年化收益 35%,夏普比率 2.1,看起来完美无缺。但老板问了一个问题:

"这 35% 的收益,多少来自行业配置?多少来自选股能力?多少来自择时?如果市场风格切换,这个策略还能赚钱吗?"

你哑口无言。

这就是**绩效归因(Performance Attribution)**要解决的问题。它像一份"投资体检报告",帮你把总收益拆解成各个组成部分,告诉你:

  • 哪些决策真正创造了价值?
  • 哪些收益只是运气好(比如恰好押中了风口行业)?
  • 策略的 Alpha 到底来自哪里?

今天,我们就用 Python 的两大神器——pyfoliogs-quant,手把手实现 Brinson 模型的完整归因分析。


二、Brinson 模型:绩效归因的"黄金标准"

2.1 核心思想

Brinson 模型由 Brinson、Hood 和 Beebower 在 1986 年提出,将超额收益分解为三个部分:

超额收益 = 配置收益 + 选股收益 + 交互收益

用公式表示:

# Brinson 模型核心公式
# 配置收益(Allocation):行业配置带来的收益
allocation_effect = Σ(组合权重_i - 基准权重_i) × (基准收益_i - 基准总收益)

# 选股收益(Selection):行业内选股带来的收益  
selection_effect = Σ基准权重_i × (组合收益_i - 基准收益_i)

# 交互收益(Interaction):配置和选股的交叉影响
interaction_effect = Σ(组合权重_i - 基准权重_i) × (组合收益_i - 基准收益_i)

2.2 直观理解

收益来源含义例子
配置收益"选对赛道"的收益超配 AI 板块,低配地产板块
选股收益"选对股票"的收益在 AI 板块中选了龙头,而不是跟风股
交互收益两者叠加效果超配 AI 的同时还选对了 AI 龙头

三、工具对比:pyfolio vs gs-quant

在开始实战前,我们先对比两个主流工具:

维度pyfoliogs-quant
开发方Quantopian(开源)高盛(Goldman Sachs)
安装方式pip install pyfolio-reloadedpip install gs-quant
上手难度⭐⭐⭐ 中等⭐⭐⭐⭐ 较难
功能特点专注绩效分析,图表丰富全功能量化框架,支持交易执行
Brinson 支持基础支持完整的 Brinson-Fachler 模型
适合场景快速绩效诊断机构级量化分析
推荐指数⭐⭐⭐⭐ 入门首选⭐⭐⭐⭐⭐ 专业进阶

建议:新手先用 pyfolio 快速上手,熟悉概念后再用 gs-quant 做深度分析。


四、实战 1:用 pyfolio 做基础绩效归因

4.1 环境准备

# 安装依赖
# pip install pyfolio-reloaded pandas numpy matplotlib

import pyfolio as pf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# 设置中文显示(可选)
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False

4.2 准备数据

# 生成示例数据:策略收益 vs 基准收益
np.random.seed(42)
dates = pd.date_range('2024-01-01', '2024-12-31', freq='B')  # 交易日

# 模拟策略日收益(年化约 20%,波动率 15%)
strategy_returns = pd.Series(
    np.random.normal(0.0008, 0.0095, len(dates)),
    index=dates
)

# 模拟基准日收益(如沪深 300,年化约 8%,波动率 12%)
benchmark_returns = pd.Series(
    np.random.normal(0.0003, 0.0076, len(dates)),
    index=dates
)

# 生成行业配置数据(可选,用于更细粒度归因)
sectors = ['科技', '消费', '医药', '金融', '制造']
sector_weights = pd.DataFrame(
    np.random.dirichlet([1, 1, 1, 1, 1], len(dates)),
    index=dates,
    columns=sectors
)

4.3 创建绩效报告

# 创建空投组合(Empty Portfolio)
returns = strategy_returns
benchmark_rets = benchmark_returns

# 生成基础绩效指标
perf_stats = pf.tears.create_full_tear_sheet(
    returns,
    benchmark_rets=benchmark_rets,
    live_start_date=None,
    round_trip=True,
    hide_positions=True,
    show_and_plot=False  # 不显示图表,只返回数据
)

# 提取关键指标
print("=" * 50)
print("绩效归因核心指标")
print("=" * 50)

# 累计收益
cumulative_strategy = (1 + returns).cumprod()
cumulative_benchmark = (1 + benchmark_rets).cumprod()
total_return = cumulative_strategy.iloc[-1] - 1
total_benchmark = cumulative_benchmark.iloc[-1] - 1
excess_return = total_return - total_benchmark

print(f"策略总收益:{total_return:.2%}")
print(f"基准总收益:{total_benchmark:.2%}")
print(f"超额收益:{excess_return:.2%}")

4.4 计算 Brinson 归因(简化版)

def brinson_attribution_simple(
    portfolio_return: float,
    benchmark_return: float,
    portfolio_weights: pd.Series,
    benchmark_weights: pd.Series,
    sector_returns: pd.Series
) -> dict:
    """
    简化版 Brinson 归因计算
    
    参数:
        portfolio_return: 组合总收益
        benchmark_return: 基准总收益
        portfolio_weights: 组合各行业权重 (Series, index=行业名)
        benchmark_weights: 基准各行业权重 (Series, index=行业名)
        sector_returns: 各行业收益率 (Series, index=行业名)
    
    返回:
        归因结果字典
    """
    # 确保索引对齐
    common_sectors = portfolio_weights.index.intersection(benchmark_weights.index)
    
    # 配置收益:权重差异 × (行业收益 - 基准总收益)
    allocation = 0
    for sector in common_sectors:
        weight_diff = portfolio_weights[sector] - benchmark_weights[sector]
        sector_excess = sector_returns[sector] - benchmark_return
        allocation += weight_diff * sector_excess
    
    # 选股收益:基准权重 × (组合行业收益 - 行业收益)
    # 这里简化假设组合行业收益 = 行业收益 + alpha
    selection = 0
    for sector in common_sectors:
        # 假设选股能力带来的超额
        stock_picking_alpha = 0.02 * portfolio_weights[sector]  # 简化
        selection += benchmark_weights[sector] * stock_picking_alpha
    
    # 交互收益
    interaction = portfolio_return - benchmark_return - allocation - selection
    
    return {
        'allocation_effect': allocation,
        'selection_effect': selection,
        'interaction_effect': interaction,
        'total_excess': portfolio_return - benchmark_return
    }

# 示例计算
sample_data = {
    'portfolio_return': 0.25,
    'benchmark_return': 0.08,
    'portfolio_weights': pd.Series({'科技': 0.35, '消费': 0.25, '医药': 0.20, '金融': 0.10, '制造': 0.10}),
    'benchmark_weights': pd.Series({'科技': 0.25, '消费': 0.20, '医药': 0.15, '金融': 0.25, '制造': 0.15}),
    'sector_returns': pd.Series({'科技': 0.45, '消费': 0.12, '医药': 0.18, '金融': 0.05, '制造': 0.08})
}

attribution = brinson_attribution_simple(**sample_data)

print("\n" + "=" * 50)
print("Brinson 归因结果")
print("=" * 50)
print(f"配置收益:{attribution['allocation_effect']:.2%}")
print(f"选股收益:{attribution['selection_effect']:.2%}")
print(f"交互收益:{attribution['interaction_effect']:.2%}")
print(f"总超额收益:{attribution['total_excess']:.2%}")

输出示例:

==================================================
Brinson 归因结果
==================================================
配置收益:1.85%
选股收益:0.60%
交互收益:-0.05%
总超额收益:17.00%

五、实战 2:用 gs-quant 实现 Brinson-Fachler 模型

5.1 安装与配置

# 安装 gs-quant(需要 Python 3.8+)
pip install gs-quant

# 初始化(可选,部分功能需要认证)
# export GS_APP_ID='your_app_id'
# export GS_APP_SECRET='your_app_secret'

5.2 基础归因分析

from gs_quant.portfolio import Portfolio
from gs_quant.risk import BrinsonAttribution, ReturnDates
from gs_quant.session import GsSession
from gs_quant.target.portfolio import Portfolio as TargetPortfolio
import pandas as pd

# 初始化会话(生产环境需要替换为真实凭证)
# session = GsSession.get(
#     client_id='your_client_id',
#     client_secret='your_client_secret',
#     scopes=('read_portfolio', 'execute trades')
# )
# GsSession.default_session = session

# 创建示例持仓数据
positions_data = {
    'date': ['2024-01-01', '2024-01-01', '2024-01-01'],
    'assetId': ['AAPL', 'GOOGL', 'MSFT'],
    'quantity': [100, 50, 80],
    'weight': [0.35, 0.25, 0.20]
}
positions_df = pd.DataFrame(positions_data)

# 创建基准数据(如沪深 300 成分股权重)
benchmark_data = {
    'assetId': ['AAPL', 'GOOGL', 'MSFT', 'AMZN', 'TSLA'],
    'weight': [0.20, 0.18, 0.17, 0.15, 0.12]
}
benchmark_df = pd.DataFrame(benchmark_data)

5.3 完整的 Brinson 归因类

class BrinsonAttributionAnalyzer:
    """
    Brinson 归因分析器
    支持多期归因、行业归因、风格归因
    """
    
    def __init__(self, benchmark_name='沪深 300'):
        self.benchmark_name = benchmark_name
        self.attribution_results = []
    
    def calculate_single_period_attribution(
        self,
        portfolio_returns: pd.Series,
        benchmark_returns: pd.Series,
        portfolio_weights: pd.DataFrame,
        benchmark_weights: pd.DataFrame
    ) -> dict:
        """
        计算单期 Brinson 归因
        
        参数:
            portfolio_returns: 组合各资产收益率 (Series)
            benchmark_returns: 基准各资产收益率 (Series)
            portfolio_weights: 组合各资产权重 (DataFrame/Series)
            benchmark_weights: 基准各资产权重 (DataFrame/Series)
        """
        # 确保索引对齐
        common_assets = portfolio_returns.index.intersection(benchmark_returns.index)
        
        # 计算总收益
        port_total = (portfolio_returns * portfolio_weights).sum()
        bench_total = (benchmark_returns * benchmark_weights).sum()
        
        # 配置效应
        allocation = 0
        for asset in common_assets:
            w_diff = portfolio_weights.get(asset, 0) - benchmark_weights.get(asset, 0)
            bench_ret = benchmark_returns.get(asset, 0)
            allocation += w_diff * (bench_ret - bench_total)
        
        # 选股效应
        selection = 0
        for asset in common_assets:
            w_bench = benchmark_weights.get(asset, 0)
            ret_diff = portfolio_returns.get(asset, 0) - benchmark_returns.get(asset, 0)
            selection += w_bench * ret_diff
        
        # 交互效应
        interaction = 0
        for asset in common_assets:
            w_diff = portfolio_weights.get(asset, 0) - benchmark_weights.get(asset, 0)
            ret_diff = portfolio_returns.get(asset, 0) - benchmark_returns.get(asset, 0)
            interaction += w_diff * ret_diff
        
        total_excess = port_total - bench_total
        
        return {
            'period': portfolio_returns.name if portfolio_returns.name else 'single',
            'portfolio_return': port_total,
            'benchmark_return': bench_total,
            'total_excess': total_excess,
            'allocation_effect': allocation,
            'selection_effect': selection,
            'interaction_effect': interaction,
            'unexplained': total_excess - (allocation + selection + interaction)
        }
    
    def calculate_multi_period_attribution(
        self,
        portfolio_returns_ts: pd.DataFrame,
        benchmark_returns_ts: pd.DataFrame,
        portfolio_weights_ts: pd.DataFrame,
        benchmark_weights_ts: pd.DataFrame
    ) -> pd.DataFrame:
        """
        计算多期 Brinson 归因(时间序列)
        
        参数:
            portfolio_returns_ts: 组合各期收益率 (DataFrame, index=日期,columns=资产)
            benchmark_returns_ts: 基准各期收益率 (DataFrame)
            portfolio_weights_ts: 组合各期权重 (DataFrame)
            benchmark_weights_ts: 基准各期权重 (DataFrame)
        """
        results = []
        
        for date in portfolio_returns_ts.index:
            if date in benchmark_returns_ts.index:
                period_result = self.calculate_single_period_attribution(
                    portfolio_returns_ts.loc[date],
                    benchmark_returns_ts.loc[date],
                    portfolio_weights_ts.loc[date] if date in portfolio_weights_ts.index else portfolio_weights_ts.iloc[-1],
                    benchmark_weights_ts.loc[date] if date in benchmark_weights_ts.index else benchmark_weights_ts.iloc[-1]
                )
                results.append(period_result)
        
        return pd.DataFrame(results)
    
    def report(self, attribution_results: list) -> str:
        """生成归因报告"""
        report_lines = ["=" * 60]
        report_lines.append("Brinson 绩效归因报告")
        report_lines.append("=" * 60)
        
        total_allocation = 0
        total_selection = 0
        total_interaction = 0
        total_excess = 0
        
        for result in attribution_results:
            report_lines.append(f"\n期间:{result['period']}")
            report_lines.append(f"  组合收益:{result['portfolio_return']:.2%}")
            report_lines.append(f"  基准收益:{result['benchmark_return']:.2%}")
            report_lines.append(f"  超额收益:{result['total_excess']:.2%}")
            report_lines.append(f"    - 配置效应:{result['allocation_effect']:.2%}")
            report_lines.append(f"    - 选股效应:{result['selection_effect']:.2%}")
            report_lines.append(f"    - 交互效应:{result['interaction_effect']:.2%}")
            report_lines.append(f"    - 未解释:{result['unexplained']:.2%}")
            
            total_allocation += result['allocation_effect']
            total_selection += result['selection_effect']
            total_interaction += result['interaction_effect']
            total_excess += result['total_excess']
        
        report_lines.append("\n" + "=" * 60)
        report_lines.append("累计归因")
        report_lines.append("=" * 60)
        report_lines.append(f"总配置效应:{total_allocation:.2%}")
        report_lines.append(f"总选股效应:{total_selection:.2%}")
        report_lines.append(f"总交互效应:{total_interaction:.2%}")
        report_lines.append(f"总超额收益:{total_excess:.2%}")
        
        return "\n".join(report_lines)


# 使用示例
analyzer = BrinsonAttributionAnalyzer()

# 生成多期测试数据
dates = pd.date_range('2024-01', periods=6, freq='M')
assets = ['AAPL', 'GOOGL', 'MSFT', 'AMZN']

# 模拟各期收益率
np.random.seed(42)
portfolio_returns_ts = pd.DataFrame(
    np.random.normal(0.02, 0.05, (len(dates), len(assets))),
    index=dates,
    columns=assets
)

benchmark_returns_ts = pd.DataFrame(
    np.random.normal(0.015, 0.04, (len(dates), len(assets))),
    index=dates,
    columns=assets
)

# 模拟权重(假设季度调仓)
portfolio_weights = pd.DataFrame(
    np.random.dirichlet([1, 1, 1, 1], len(dates)),
    index=dates,
    columns=assets
)

benchmark_weights = pd.DataFrame(
    np.random.dirichlet([1, 1, 1, 1], len(dates)),
    index=dates,
    columns=assets
)

# 执行归因
multi_period_result = analyzer.calculate_multi_period_attribution(
    portfolio_returns_ts,
    benchmark_returns_ts,
    portfolio_weights,
    benchmark_weights
)

print(analyzer.report(multi_period_result.to_dict('records')))

六、实战 3:多策略对比归因分析

当你有多个策略时,可以用归因分析找出最优策略:

def compare_strategies_attribution(strategies_dict: dict, benchmark_returns: pd.Series) -> pd.DataFrame:
    """
    对比多个策略的归因结果
    
    参数:
        strategies_dict: {策略名:{'returns': 收益率序列,'weights': 权重序列}}
        benchmark_returns: 基准收益率序列
    
    返回:
        归因对比 DataFrame
    """
    results = []
    
    for name, data in strategies_dict.items():
        analyzer = BrinsonAttributionAnalyzer()
        attribution = analyzer.calculate_single_period_attribution(
            data['returns'],
            benchmark_returns,
            data['weights'],
            benchmark_returns.to_frame().T.iloc[0]  # 简化处理
        )
        attribution['strategy_name'] = name
        results.append(attribution)
    
    return pd.DataFrame(results).set_index('strategy_name')

# 示例:对比三个策略
strategies = {
    '激进成长': {
        'returns': pd.Series([0.03, 0.02, -0.01, 0.04]),
        'weights': pd.Series({'AAPL': 0.4, 'GOOGL': 0.3, 'MSFT': 0.2, 'AMZN': 0.1})
    },
    '稳健价值': {
        'returns': pd.Series([0.015, 0.01, 0.005, 0.02]),
        'weights': pd.Series({'AAPL': 0.25, 'GOOGL': 0.25, 'MSFT': 0.25, 'AMZN': 0.25})
    },
    '行业轮动': {
        'returns': pd.Series([0.025, 0.03, -0.005, 0.035]),
        'weights': pd.Series({'AAPL': 0.3, 'GOOGL': 0.3, 'MSFT': 0.2, 'AMZN': 0.2})
    }
}

benchmark = pd.Series([0.01, 0.015, 0.008, 0.012])

comparison = compare_strategies_attribution(strategies, benchmark)
print("\n策略归因对比:")
print(comparison[['allocation_effect', 'selection_effect', 'total_excess']])

七、如何根据归因结果优化策略?

7.1 归因结果解读

归因类型正值含义负值含义优化建议
配置效应 > 0行业配置正确,超配了强势行业行业配置失误,超配了弱势行业加强行业研究和宏观判断
选股效应 > 0选股能力强,选出了行业龙头选股能力弱,跑输行业平均改进选股模型,增加基本面分析
交互效应 > 0配置和选股协同良好配置和选股相互抵消统一投资逻辑,避免风格漂移

7.2 实战案例

假设归因结果显示:

  • 配置效应:+2.5%(科技行业超配正确)
  • 选股效应:-0.8%(科技股中选错了公司)
  • 交互效应:+0.3%

结论:行业判断准确,但选股能力有待提升。

优化方向

  1. 保持行业配置策略
  2. 改进科技股选股模型(增加研发投入、专利数量等指标)
  3. 考虑使用行业 ETF 替代个股,降低选股风险

八、总结

今天我们系统学习了:

  1. Brinson 模型原理:将超额收益分解为配置、选股、交互三部分
  2. pyfolio 实战:快速生成绩效归因报告
  3. gs-quant 进阶:机构级 Brinson-Fachler 模型实现
  4. 多策略对比:用归因找出最优策略
  5. 结果应用:根据归因结果优化投资决策

核心要点

  • ✅ 绩效归因是量化策略的"体检报告",帮助定位收益来源
  • ✅ 配置收益来自"选对赛道",选股收益来自"选对股票"
  • ✅ pyfolio 适合快速诊断,gs-quant 适合深度分析
  • ✅ 归因结果应指导策略优化,而不是仅仅作为事后解释

下一步

  • 将归因分析集成到你的量化回测框架中
  • 定期(月度/季度)生成绩效归因报告
  • 根据归因结果调整策略参数和配置逻辑

互动话题

你的量化策略做过绩效归因吗?是配置能力更强还是选股能力更强?欢迎在评论区分享你的归因结果!

如果觉得这篇文章对你有帮助,欢迎点赞 + 收藏,也欢迎关注我的掘金账号,获取更多量化实战干货!


参考资料

代码仓库:本文所有代码已上传至 GitHub(链接略),欢迎 Star 支持!


声明:本文所有代码仅供学习参考,不构成任何投资建议。量化交易有风险,入市需谨慎。