业绩归因分析:你的收益来自 alpha 还是 beta?
“收益率只是一个结果,理解它背后的来源才是区分职业选手和赌徒的关键。”
2024 年上半年,一位量化基金经理向笔者展示了他的策略监控面板:年化收益 23.6%,夏普比率 2.1,最大回撤仅 8%。数据漂亮,但他自己的问题是:“这 23.6% 里,有多少是市场 beta,有多少是行业轮动的 beta,有多少是真正的选股 alpha?”
他没有答案。
这不是他一个人的困境。绝大多数量化策略的复盘停留在"赚了多少钱"的层面,很少深入到"钱是从哪个风险源赚的"。这导致一个致命问题:把 beta 当 alpha 宣传,把市场馈赠当个人能力炫耀。一旦 beta 消退,策略裸奔,亏损随之而来。
本文提供一套生产级业绩归因框架,拆解收益来源的五个层次,并给出可直接复用的 Python 实现。
一、为什么你的策略收益需要归因
1.1 收益的五个层次
策略总收益并非单一来源,而是多个层次收益的叠加:
| 收益层次 | 来源 | 可持续性 | 归因意义 |
|---|---|---|---|
| 市场 beta | 大盘整体涨跌 | 周期性 | 判断策略是否在"靠天吃饭" |
| 行业 beta | 持仓行业整体表现 | 中期轮动 | 判断是否押注单一赛道 |
| 风格 beta | 价值/成长/动量等因子暴露 | 风格切换 | 判断收益是否来自风格漂移 |
| 配置收益 | 行业间资金分配决策 | 取决于轮动判断 | Brinson 归因的核心 |
| 选股收益 | 个股选择的超额收益 | 真正的 alpha | 需要剔除非主动因素后才能确认 |
一个典型的"伪 alpha"案例:2020-2021 年重仓新能源的主动基金,普遍跑赢基准 30%+,基金经理被视为"大神"。但如果剔除新能源行业 beta,这些基金有多少选股 alpha?答案令人尴尬:大部分基金经理的超额收益在行业选择上,而非个股选择上。
1.2 归因分析的三个核心问题
在进行归因之前,需要明确回答三个问题:
- 基准是什么? 相对收益的"相对"需要一个锚。沪深 300、中证 500、还是自定义基准?
- 持仓周期是什么? 日频调仓和月频调仓的归因模型完全不同。
- 分解精度是什么? 是要大类拆分(股债商品),还是要精细到因子暴露?
本文聚焦权益类多因子策略,基准设定为沪深 300,采用双重归因框架:
- Brinson 归因:拆解配置收益与选股收益
- Fama-French 因子模型:拆解风格暴露与残差 alpha
二、归因分析的系统架构
2.1 整体架构
业绩归因系统分为四个模块:
数据获取层
↓
持仓快照解析
↓
收益来源分解(Brinson + Fama-French)
↓
可视化与报告生成
2.2 Brinson 归因:三因素分解
Brinson 模型将超额收益分解为三个部分:
| 归因因子 | 含义 | 理想状态 |
|---|---|---|
| 配置效应 | 超配行业涨得多,低配行业跌得少 | 配置效应为正 |
| 选股效应 | 在行业内部选中涨幅更高的股票 | 选股效应为正 |
| 交互效应 | 超配涨得多的行业 AND 选中行业内更好的股 | 交互项越小越好(难以单独归因) |
公式:
$$
\text{超额收益} = \underbrace{\sum_i (w_i^{portfolio} - w_i^{benchmark}) \cdot r_i^{benchmark}}{\text{配置效应}} + \underbrace{\sum_i w_i^{benchmark} \cdot (r_i^{portfolio} - r_i^{benchmark})}{\text{选股效应}} + \underbrace{\text{交互项}}_{\text{残差}}
$$
2.3 Fama-French 因子暴露模型
Fama-French 三因子模型的核心公式:
$$
R_i - R_f = \alpha_i + \beta_i^{MKT} \cdot (R_m - R_f) + \beta_i^{SMB} \cdot SMB + \beta_i^{HML} \cdot HML + \epsilon_i
$$
| 因子 | 全称 | 经济含义 |
|---|---|---|
| MKT | 市场超额收益 | 承担市场系统性风险获得的补偿 |
| SMB | Small Minus Big | 市值因子:小盘股相对大盘股的溢价 |
| HML | High Minus Low | 价值因子:低估值股票相对高估值股票的溢价 |
如果你的策略年化收益 15%,但 MKT 因子暴露为 0.8(即与大盘相关性很高),且回测期间大盘上涨 10%,那么市场贡献的收益约为 8%,真正的 alpha 只有 7%。
三、生产级代码实现
以下代码实现完整的业绩归因分析,包括数据获取、因子暴露计算、Brinson 分解和可视化。
3.1 数据获取模块
import os
import time
import json
import random
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
import requests
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
# ⚠️ 生产环境高频场景建议使用 aiohttp/asyncio
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class TickDBClient:
"""
TickDB API 客户端 - 生产级实现
包含:心跳保活、指数退避重连、限频处理、超时设置
"""
def __init__(self, api_key: Optional[str] = None, base_url: str = "https://api.tickdb.ai"):
self.api_key = api_key or os.environ.get("TICKDB_API_KEY")
if not self.api_key:
raise ValueError("API Key 未设置,请设置环境变量 TICKDB_API_KEY")
self.base_url = base_url
self.session = requests.Session()
self.session.headers.update({"X-API-Key": self.api_key})
# 重连配置
self.max_retries = 5
self.base_delay = 1.0
self.max_delay = 32.0
def _request_with_retry(self, method: str, endpoint: str, **kwargs) -> dict:
"""
带指数退避和抖动的重试机制
"""
kwargs.setdefault("timeout", (3.05, 10)) # Connect timeout, Read timeout
for retry in range(self.max_retries):
try:
response = self.session.request(method, f"{self.base_url}{endpoint}", **kwargs)
# 限频处理
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 5))
logger.warning(f"触发限频,等待 {retry_after} 秒")
time.sleep(retry_after)
continue
result = response.json()
# 业务层限频错误码处理
if result.get("code") == 3001:
retry_after = int(result.get("retry_after", 5))
logger.warning(f"业务层限频 code:3001,等待 {retry_after} 秒")
time.sleep(retry_after)
continue
if result.get("code") != 0:
raise RuntimeError(f"API 错误 {result.get('code')}: {result.get('message')}")
return result.get("data", result)
except requests.exceptions.Timeout:
logger.warning(f"请求超时,重试 ({retry + 1}/{self.max_retries})")
except requests.exceptions.ConnectionError as e:
logger.warning(f"连接错误: {e},重试 ({retry + 1}/{self.max_retries})")
except Exception as e:
logger.error(f"未知错误: {e}")
raise
# 指数退避 + 抖动
if retry < self.max_retries - 1:
delay = min(self.base_delay * (2 ** retry), self.max_delay)
jitter = random.uniform(0, delay * 0.1)
time.sleep(delay + jitter)
raise RuntimeError(f"达到最大重试次数 {self.max_retries}")
def get_kline(self, symbol: str, interval: str = "1d",
start_time: Optional[int] = None,
end_time: Optional[int] = None,
limit: int = 500) -> pd.DataFrame:
"""
获取 K 线数据用于回测
"""
params = {
"symbol": symbol,
"interval": interval,
"limit": limit
}
if start_time:
params["start_time"] = start_time
if end_time:
params["end_time"] = end_time
data = self._request_with_retry("GET", "/v1/market/kline", params=params)
df = pd.DataFrame(data)
df["timestamp"] = pd.to_datetime(df["t"], unit="ms")
df.set_index("timestamp", inplace=True)
return df
def get_symbols(self, market: str = "us") -> List[str]:
"""
获取可用交易品种列表
"""
data = self._request_with_retry("GET", "/v1/symbols/available", params={"market": market})
return [s["symbol"] for s in data]
3.2 持仓数据与收益计算模块
class Portfolio:
"""
模拟持仓数据 + 收益计算
生产环境中应从交易系统直接读取持仓快照
"""
def __init__(self, client: TickDBClient):
self.client = client
def get_portfolio_holdings(self, date: str) -> pd.DataFrame:
"""
获取指定日期的持仓快照
返回格式:[symbol, weight, shares, cost_basis]
"""
# ⚠️ 此处为演示数据,生产环境应从交易系统获取
return pd.DataFrame({
"symbol": ["AAPL.US", "MSFT.US", "GOOGL.US", "AMZN.US", "NVDA.US",
"META.US", "TSLA.US", "BRK-B.US", "JPM.US", "V.US"],
"weight": [0.15, 0.12, 0.10, 0.10, 0.08,
0.10, 0.08, 0.09, 0.10, 0.08],
"sector": ["Technology", "Technology", "Technology", "Consumer Discretionary", "Technology",
"Technology", "Consumer Discretionary", "Financials", "Financials", "Financials"]
})
def get_benchmark_weights(self, date: str) -> pd.DataFrame:
"""
获取基准指数权重
此处使用等权模拟,生产环境应使用实际指数成分
"""
return pd.DataFrame({
"symbol": ["AAPL.US", "MSFT.US", "GOOGL.US", "AMZN.US", "NVDA.US",
"META.US", "TSLA.US", "BRK-B.US", "JPM.US", "V.US"],
"weight": [0.10, 0.10, 0.10, 0.10, 0.10,
0.10, 0.10, 0.10, 0.10, 0.10],
"sector": ["Technology", "Technology", "Technology", "Consumer Discretionary", "Technology",
"Technology", "Consumer Discretionary", "Financials", "Financials", "Financials"]
})
class ReturnCalculator:
"""
收益计算引擎
"""
def __init__(self, client: TickDBClient):
self.client = client
def calculate_returns(self, symbols: List[str],
start_date: str, end_date: str) -> pd.DataFrame:
"""
计算各标的日收益率序列
"""
returns = {}
for symbol in symbols:
try:
start_ts = int(pd.Timestamp(start_date).timestamp() * 1000)
end_ts = int(pd.Timestamp(end_date).timestamp() * 1000)
df = self.client.get_kline(
symbol,
interval="1d",
start_time=start_ts,
end_time=end_ts,
limit=500
)
# 计算日收益率
df["return"] = df["c"].pct_change()
returns[symbol] = df["return"]
except Exception as e:
logger.warning(f"获取 {symbol} 数据失败: {e}")
continue
result = pd.DataFrame(returns)
return result.dropna()
3.3 Fama-French 因子暴露计算
class FactorAttribution:
"""
Fama-French 因子暴露分析
⚠️ 注意:完整的 Fama-French 因子数据需要从Kenneth French数据库获取
此处使用简化模拟,实际生产应接入真实因子数据
"""
def __init__(self):
# 因子描述统计(历史均值与波动率,用于模拟)
self.factor_stats = {
"MKT": {"mean": 0.0003, "std": 0.012}, # 市场超额收益日频
"SMB": {"mean": 0.0001, "std": 0.008}, # 小市值因子
"HML": {"mean": 0.0002, "std": 0.007}, # 价值因子
}
def generate_factor_returns(self, dates: pd.DatetimeIndex,
seed: int = 42) -> pd.DataFrame:
"""
生成模拟因子收益率序列
⚠️ 生产环境应使用 KenFrench 数据库或 Wind 因子数据
"""
np.random.seed(seed)
n = len(dates)
factors = {}
for factor, stats in self.factor_stats.items():
factors[factor] = np.random.normal(stats["mean"], stats["std"], n)
return pd.DataFrame(factors, index=dates)
def calculate_portfolio_exposure(self, portfolio_returns: pd.Series,
factor_returns: pd.DataFrame) -> Dict:
"""
使用 OLS 回归计算组合因子暴露
"""
from scipy import stats
# 对齐日期
common_dates = portfolio_returns.index.intersection(factor_returns.index)
y = portfolio_returns.loc[common_dates]
X = factor_returns.loc[common_dates]
X = sm.add_constant(X) # 添加常数项
# OLS 回归
model = sm.OLS(y, X).fit()
# 提取因子暴露
exposures = {
"alpha": model.params["const"],
"beta_mkt": model.params["MKT"],
"beta_smb": model.params["SMB"],
"beta_hml": model.params["HML"],
"r_squared": model.rsquared,
"residual_std": model.resid.std(),
}
# 计算各因子贡献
factor_contrib = {}
for factor in ["MKT", "SMB", "HML"]:
beta = exposures[f"beta_{factor.lower()}"]
factor_contrib[factor] = (beta * factor_returns[factor].mean()) * 252 # 年化
exposures["factor_contribution"] = factor_contrib
exposures["alpha_annualized"] = exposures["alpha"] * 252
exposures["residual_annualized"] = exposures["residual_std"] * np.sqrt(252)
return exposures, model
def generate_exposure_report(self, exposures: Dict) -> pd.DataFrame:
"""
生成因子暴露报告表格
"""
report_data = {
"指标": [
"Alpha (年化)",
"MKT 暴露",
"SMB 暴露",
"HML 暴露",
"R² (市场解释力)",
"残差波动率 (年化)"
],
"数值": [
f"{exposures['alpha_annualized']*100:.2f}%",
f"{exposures['beta_mkt']:.3f}",
f"{exposures['beta_smb']:.3f}",
f"{exposures['beta_hml']:.3f}",
f"{exposures['r_squared']*100:.1f}%",
f"{exposures['residual_annualized']*100:.2f}%"
],
"解读": [
"剔除因子暴露后的纯粹超额收益",
"与大盘的系统性风险关联",
"小盘股暴露程度",
"价值股暴露程度",
"策略收益中有多少能被因子解释",
"真正的策略特异性风险"
]
}
return pd.DataFrame(report_data)
3.4 Brinson 归因实现
class BrinsonAttribution:
"""
Brinson 归因模型实现
分解配置效应、选股效应与交互效应
"""
def run_attribution(self, portfolio: pd.DataFrame,
benchmark: pd.DataFrame,
sector_returns: pd.Series) -> pd.DataFrame:
"""
执行 Brinson 归因分析
参数:
portfolio: 组合权重 [symbol, weight, sector]
benchmark: 基准权重 [symbol, weight, sector]
sector_returns: 各行业收益率序列
"""
# 按行业聚合
portfolio_sector = portfolio.groupby("sector")["weight"].sum()
benchmark_sector = benchmark.groupby("sector")["weight"].sum()
# 计算各行业收益率
sector_return_map = {}
for sector in portfolio["sector"].unique():
sector_return_map[sector] = sector_returns[sector] if sector in sector_returns else 0
# 基准组合行业收益率(按权重加权)
benchmark_return = sum(
benchmark_sector[s] * sector_return_map.get(s, 0)
for s in benchmark_sector.index
)
# Brinson 归因分解
attribution_results = []
for sector in portfolio["sector"].unique():
p_w = portfolio_sector.get(sector, 0)
b_w = benchmark_sector.get(sector, 0)
r = sector_return_map.get(sector, 0)
b_r = benchmark_return # 简化:使用组合整体作为基准
# 配置效应:行业权重偏离 × 基准收益率
allocation_effect = (p_w - b_w) * r
# 选股效应:行业内部选股超额
# 简化版:假设组合内个股等权,实际应计算个股超额
stock_selection_effect = b_w * (r - b_r)
# 交互效应
interaction_effect = (p_w - b_w) * (r - b_r)
attribution_results.append({
"sector": sector,
"portfolio_weight": p_w,
"benchmark_weight": b_w,
"weight_diff": p_w - b_w,
"sector_return": r,
"allocation_effect": allocation_effect,
"stock_selection_effect": stock_selection_effect,
"interaction_effect": interaction_effect,
"total_effect": allocation_effect + stock_selection_effect + interaction_effect
})
return pd.DataFrame(attribution_results)
def print_attribution_summary(self, attr_df: pd.DataFrame):
"""
打印归因汇总表
"""
print("\n" + "="*70)
print("Brinson 归因分析结果")
print("="*70)
summary = attr_df[[
"sector", "weight_diff", "allocation_effect",
"stock_selection_effect", "total_effect"
]].copy()
summary.columns = ["行业", "权重偏离", "配置效应", "选股效应", "总效应"]
# 格式化百分比
for col in ["权重偏离", "配置效应", "选股效应", "总效应"]:
summary[col] = summary[col].apply(lambda x: f"{x*100:.2f}%")
print(summary.to_string(index=False))
# 汇总行
total_allocation = attr_df["allocation_effect"].sum()
total_selection = attr_df["stock_selection_effect"].sum()
total_interaction = attr_df["interaction_effect"].sum()
print("\n" + "-"*70)
print(f"配置效应合计: {total_allocation*100:+.2f}%")
print(f"选股效应合计: {total_selection*100:+.2f}%")
print(f"交互效应合计: {total_interaction*100:+.2f}%")
print(f"超额收益总计: {(total_allocation + total_selection + total_interaction)*100:+.2f}%")
print("="*70)
3.5 可视化模块
class AttributionVisualizer:
"""
归因结果可视化
"""
def __init__(self, style: str = "seaborn-v0_8-darkgrid"):
plt.style.use(style)
def plot_factor_exposure_heatmap(self, exposures_dict: Dict):
"""
因子暴露热力图
"""
fig, ax = plt.subplots(figsize=(10, 4))
# 准备数据
factors = ["MKT", "SMB", "HML"]
betas = [exposures_dict["beta_mkt"], exposures_dict["beta_smb"], exposures_dict["beta_hml"]]
data = np.array([betas])
sns.heatmap(data, annot=True, fmt=".3f", cmap="RdYlGn", center=0,
xticklabels=factors, yticklabels=["因子暴露"],
ax=ax, vmin=-1, vmax=1)
ax.set_title("Fama-French 因子暴露分析", fontsize=14, fontweight="bold")
plt.tight_layout()
plt.savefig("factor_exposure_heatmap.png", dpi=150)
plt.show()
def plot_returns_decomposition(self, factor_contrib: Dict, alpha: float):
"""
收益分解堆叠柱状图
"""
fig, ax = plt.subplots(figsize=(10, 6))
categories = ["Alpha"] + list(factor_contrib.keys())
values = [alpha] + list(factor_contrib.values())
colors = ["#2ecc71"] + ["#3498db"] * len(factor_contrib)
bars = ax.barh(categories, values, color=colors, edgecolor="white", linewidth=1.2)
# 添加数值标签
for bar, val in zip(bars, values):
width = bar.get_width()
ax.text(width + 0.005, bar.get_y() + bar.get_height()/2,
f"{val*100:.2f}%", va="center", fontsize=10)
ax.axvline(x=0, color="black", linewidth=0.5)
ax.set_xlabel("年化收益贡献", fontsize=12)
ax.set_title("收益来源分解", fontsize=14, fontweight="bold")
# 添加图例
from matplotlib.patches import Patch
legend_elements = [
Patch(facecolor="#2ecc71", label="Alpha (真实超额)"),
Patch(facecolor="#3498db", label="因子暴露")
]
ax.legend(handles=legend_elements, loc="lower right")
plt.tight_layout()
plt.savefig("returns_decomposition.png", dpi=150)
plt.show()
def plot_brinson_waterfall(self, attr_df: pd.DataFrame):
"""
Brinson 归因瀑布图
"""
fig, ax = plt.subplots(figsize=(12, 6))
sectors = attr_df["sector"].tolist()
allocation = (attr_df["allocation_effect"] * 100).tolist()
selection = (attr_df["stock_selection_effect"] * 100).tolist()
x = np.arange(len(sectors))
width = 0.35
bars1 = ax.bar(x - width/2, allocation, width, label="配置效应", color="#e74c3c")
bars2 = ax.bar(x + width/2, selection, width, label="选股效应", color="#9b59b6")
ax.set_ylabel("收益贡献 (%)", fontsize=12)
ax.set_title("Brinson 归因 - 行业维度分解", fontsize=14, fontweight="bold")
ax.set_xticks(x)
ax.set_xticklabels(sectors, rotation=45, ha="right")
ax.legend()
ax.axhline(y=0, color="black", linewidth=0.5)
# 添加数值标签
for bars in [bars1, bars2]:
for bar in bars:
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2., height,
f"{height:.2f}%", ha="center", va="bottom" if height > 0 else "top",
fontsize=9)
plt.tight_layout()
plt.savefig("brinson_waterfall.png", dpi=150)
plt.show()
3.6 完整归因分析运行脚本
def run_full_attribution_analysis(start_date: str, end_date: str):
"""
完整归因分析流程
"""
# 初始化客户端
client = TickDBClient()
# 初始化各模块
portfolio = Portfolio(client)
return_calc = ReturnCalculator(client)
factor_attr = FactorAttribution()
brinson = BrinsonAttribution()
visualizer = AttributionVisualizer()
# 1. 获取持仓数据
holdings = portfolio.get_portfolio_holdings(end_date)
benchmark = portfolio.get_benchmark_weights(end_date)
symbols = holdings["symbol"].tolist()
# 2. 计算收益率序列
returns = return_calc.calculate_returns(symbols, start_date, end_date)
# 3. 计算组合日收益率(按权重加权)
portfolio_returns = (returns * holdings.set_index("symbol")["weight"]).sum(axis=1)
portfolio_returns = portfolio_returns.dropna()
# 4. Fama-French 因子暴露分析
factor_returns = factor_attr.generate_factor_returns(portfolio_returns.index)
exposures, model = factor_attr.calculate_portfolio_exposure(
portfolio_returns, factor_returns
)
# 打印因子暴露报告
print("\n" + "="*70)
print("Fama-French 因子暴露分析")
print("="*70)
print(factor_attr.generate_exposure_report(exposures).to_string(index=False))
print(f"\n回归模型 R² = {exposures['r_squared']:.4f}")
print(f"年化总收益 = {portfolio_returns.mean() * 252 * 100:.2f}%")
print("="*70)
# 5. 生成可视化
visualizer.plot_factor_exposure_heatmap(exposures)
visualizer.plot_returns_decomposition(
exposures["factor_contribution"],
exposures["alpha_annualized"]
)
# 6. Brinson 归因
# 计算行业收益率(简化:使用等权组合收益率)
sector_returns = {}
sector_symbols = holdings.groupby("sector")["symbol"].apply(list).to_dict()
for sector, syms in sector_symbols.items():
sector_ret = returns[syms].mean(axis=1).iloc[-1] # 取最新一期
sector_returns[sector] = sector_ret
attr_result = brinson.run_attribution(holdings, benchmark, sector_returns)
brinson.print_attribution_summary(attr_result)
visualizer.plot_brinson_waterfall(attr_result)
return exposures, attr_result, model
if __name__ == "__main__":
# 示例运行
# start_date = "2023-01-01"
# end_date = "2024-01-01"
# exposures, attr_result, model = run_full_attribution_analysis(start_date, end_date)
pass
四、核心算法详解
4.1 Fama-French 回归的统计检验
回归结果的可信度需要通过以下统计量判断:
| 统计量 | 判断标准 | 经济含义 |
|---|---|---|
| R² | > 0.7 高解释力 | 策略收益主要由因子驱动,alpha 空间有限 |
| t-stat | > 2 显著 | 因子暴露是主动选择,非统计噪声 |
| Alpha t-stat | < 1.65 不显著 | alpha 可能只是随机波动 |
| 残差自相关 | 不应显著 | 残差应近似白噪声 |
def validate_regression(model) -> Dict:
"""
验证回归结果的统计有效性
"""
results = {
"r_squared": model.rsquared,
"adj_r_squared": model.rsquared_adj,
"f_statistic": model.fvalue,
"f_pvalue": model.f_pvalue,
"dw_statistic": sm.stats.durbin_watson(model.resid), # Durbin-Watson 检验
}
# 各系数的 t 检验
for param in model.params.index:
results[f"{param}_tstat"] = model.tvalues[param]
results[f"{param}_pvalue"] = model.pvalues[param]
# 判断
print("\n回归诊断报告:")
print(f" R² = {results['r_squared']:.4f} ", end="")
if results['r_squared'] > 0.7:
print("(高解释力,收益主要由因子驱动)")
elif results['r_squared'] > 0.4:
print("(中等解释力)")
else:
print("(低解释力,存在显著 alpha)")
print(f" D-W = {results['dw_statistic']:.3f} ", end="")
if 1.5 < results['dw_statistic'] < 2.5:
print("(残差无自相关)")
else:
print("(⚠️ 残差存在自相关,模型需改进)")
print(f" Alpha t-stat = {results['const_tstat']:.3f}, p-value = {results['const_pvalue']:.4f}")
if results['const_pvalue'] < 0.05:
print(" Alpha 显著 ≠ 0 (存在真实超额收益)")
else:
print(" ⚠️ Alpha 不显著,超额收益可能为噪声")
return results
4.2 多因子模型的扩展
Fama-French 三因子模型是基础版本,实际生产可扩展到 Carhart 四因子(+动量)或 Fama-French 五因子模型:
| 模型 | 因子数 | 新增因子 | 适用场景 |
|---|---|---|---|
| 三因子 | 3 | - | 基准分析 |
| Carhart 四因子 | 4 | MOM (动量) | 动量策略归因 |
| 五因子 | 5 | RMW (盈利), CMA (投资) | 价值策略归因 |
| 自定义多因子 | N | 任意量化因子 | 完整因子库 |
def run_custom_factor_model(portfolio_returns: pd.Series,
factor_matrix: pd.DataFrame,
factor_names: List[str]) -> Tuple[Dict, sm.regression.linear_model.RegressionResultsWrapper]:
"""
自定义多因子模型
参数:
portfolio_returns: 组合日收益率序列
factor_matrix: 因子暴露矩阵 (n_samples, n_factors)
factor_names: 因子名称列表
"""
# 对齐数据
common_idx = portfolio_returns.index.intersection(factor_matrix.index)
y = portfolio_returns.loc[common_idx]
X = factor_matrix.loc[common_idx]
X = sm.add_constant(X)
X.columns = ["const"] + factor_names
# OLS 回归
model = sm.OLS(y, X).fit()
# 提取结果
exposures = {
"alpha": model.params["const"],
**{f"beta_{name}": model.params[name] for name in factor_names},
"r_squared": model.rsquared,
}
# 计算因子收益贡献
exposures["factor_contribution"] = {}
for name in factor_names:
beta = exposures[f"beta_{name}"]
mean_factor_return = factor_matrix[name].mean()
annual_contrib = beta * mean_factor_return * 252
exposures["factor_contribution"][name] = annual_contrib
exposures["alpha_annualized"] = exposures["alpha"] * 252
return exposures, model
五、实战案例:某科技组合归因分析
5.1 案例背景
假设一个聚焦美股科技板块的量化组合,2023 年全年表现如下:
| 指标 | 数值 |
|---|---|
| 年化收益率 | 28.5% |
| 年化波动率 | 22.3% |
| 夏普比率 | 1.28 |
| 最大回撤 | 18.6% |
| 基准(纳斯达克指数) | +18.2% |
组合相对基准超额收益约 10.3%。但这 10.3% 是从哪里来的?
5.2 Fama-French 归因结果
| 指标 | 数值 | 解读 |
|---|---|---|
| Alpha (年化) | +3.8% | 剔除因子后仍有正超额 |
| MKT 暴露 | 1.12 | 与大盘高度相关 |
| SMB 暴露 | -0.35 | 偏向大盘股 |
| HML 暴露 | -0.42 | 偏向成长股 |
| R² | 0.78 | 78% 收益被因子解释 |
| 残差波动率 (年化) | 12.1% | 特异性风险较高 |
结论:该组合的 28.5% 收益中,约 18.2% 来自市场 beta,约 6.5% 来自风格暴露(成长因子),约 3.8% 来自真实选股 alpha。
5.3 Brinson 归因结果
| 行业 | 权重偏离 | 配置效应 | 选股效应 | 总效应 |
|---|---|---|---|---|
| Technology | +15.2% | +4.8% | +2.1% | +6.9% |
| Consumer Discretionary | -8.5% | -1.2% | +0.8% | -0.4% |
| Financials | -6.7% | -0.6% | +0.3% | -0.3% |
| 汇总 | - | +3.0% | +3.2% | +6.2% |
注:Brinson 归因的基准为等权组合,故超额收益约 6.2%。
结论:超额收益主要来自 科技行业超配(配置效应),其次是行业内选股(选股效应)。
5.4 归因解读与策略建议
| 问题 | 归因结论 | 策略建议 |
|---|---|---|
| 科技占比过高是否合理? | 配置效应贡献最大,但集中度风险上升 | 考虑行业再平衡 |
| Alpha 是否显著? | t-stat = 2.1,p < 0.05,统计显著 | 选股能力真实存在 |
| 是否依赖市场 beta? | MKT 暴露 1.12,beta 依赖度高 | 熊市中回撤可能更大 |
| 成长风格是否持续? | HML 暴露 -0.42,强烈成长偏好 | 关注价值因子切换风险 |
六、部署方案对比
| 方案 | 适用场景 | 数据规模 | 因子库 | 价格 |
|---|---|---|---|---|
| 个人开发者 | 单策略验证 | 持仓 < 20 只 | 基础三因子 | 免费层 API |
| 量化团队 | 多策略并行 | 持仓 < 100 只 | 全量因子库 | 专业版 |
| 机构级 | 组合管理 + 风控 | 全市场 | 自定义因子 | 企业版 |
七、总结与下一步行动
7.1 核心结论
- 收益需要归因:总收益 = beta + style + alpha,不做归因就是把 beta 当 alpha。
- Fama-French 是基准工具:R² > 0.7 意味着策略解释力在因子层面;R² < 0.4 才算真正有 alpha。
- Brinson 是配置工具:配置效应 > 选股效应 → 策略更像行业轮动;选股效应 > 配置效应 → 选股能力更强。
- 统计显著性不可忽视:alpha t-stat < 1.65 的"超额收益"大概率是噪声。
7.2 分层 CTA
如果你是普通投资者,想了解自己的基金收益来源,关注 TickDB 公众号获取业绩归因基础教程。
如果你希望亲手实现归因分析:
- 访问 tickdb.ai 注册(免费,无需信用卡)
- 在控制台生成 API Key
- 使用本文提供的代码,设置环境变量
TICKDB_API_KEY即可运行
如果你需要机构级因子库(完整 Fama-French 五因子 + Barra 风格因子),联系 [email protected] 了解企业版方案。
如果你习惯用 AI 辅助开发,在 AI 助手中搜索安装 tickdb-market-data SKILL,语音描述即可生成归因分析代码。
风险提示:本文不构成任何投资建议。历史业绩不代表未来表现。因子暴露会随市场风格切换而变化,建议定期复盘归因结果。
本文代码基于 TickDB API v1 构建,如遇接口变更请参阅官方文档。