先有收益率,为什么还不够?
前面做回测时,很自然会先看年化收益。可把两只标的放在一起比较,问题就出来了:一只年化收益 30%、波动 50%;另一只年化收益 15%、波动 10%。前者赚得更多,却未必更容易持有。
假设无风险利率为 4%,股票 A 的超额收益是 26%,除以 50% 波动后约为 0.52;股票 B 的超额收益是 11%,但除以 10% 波动后约为 1.1。单看收益会选 A,按承担的风险来算,B 的表现反而更有效率。
这一章的两个指标分别补上了两个视角:夏普比率看"多承担一单位波动,换来了多少超额收益";Beta 则看"市场波动时,标的通常有多敏感"。它们不是用来预测涨跌,而是避免只用一个收益率下判断。
夏普比率:把收益放回风险里看
夏普比率的公式是:
Sharpe Ratio = E ( R p ) − R f σ p \text{Sharpe Ratio} = \frac{E(R_p) - R_f}{\sigma_p} Sharpe Ratio=σpE(Rp)−Rf
其中 E ( R p ) E(R_p) E(Rp) 是投资组合预期收益率, R f R_f Rf 是无风险利率, σ p \sigma_p σp 是收益率标准差。学习阶段可以先把它理解为:
Sharpe ≈ 年化收益 − 无风险利率 年化波动率 \text{Sharpe} \approx \frac{\text{年化收益} - \text{无风险利率}}{\text{年化波动率}} Sharpe≈年化波动率年化收益−无风险利率
我更愿意把它看成风险调整后的"性价比",而不是一个单独的好坏标签。通常 Sharpe 大于 1 说明风险调整后的收益还不错;大于 2 已经相当亮眼;小于 0 则说明样本期内连无风险利率都没有跑赢。但这些阈值只能帮助初步阅读结果,不能替代对样本期和策略逻辑的检查。
这里有两个容易漏掉的细节。第一,无风险利率的频率必须和收益率一致:用日收益计算时,不能直接把年化 4% 当作日利率相减。第二,夏普把上涨和下跌造成的波动都计入风险,所以它不专门惩罚下跌。市场收益还可能有厚尾,历史上好看的 Sharpe 也会随样本期变化。
Beta:它描述的是相对市场的敏感度
Beta 的定义为:
β = Cov ( R 股票 , R 大盘 ) Var ( R 大盘 ) \beta = \frac{\text{Cov}(R_{\text{股票}}, R_{\text{大盘}})}{\text{Var}(R_{\text{大盘}})} β=Var(R大盘)Cov(R股票,R大盘)
一开始很容易把 Beta=1.2 理解成"每天都比大盘多涨或多跌 20%",这并不准确。它是基于历史收益序列的统计关系:如果 Beta 大于 1,标的对市场波动通常更敏感;小于 1 则相对没那么敏感;接近 1 表示与市场的联动程度接近基准。
所以 Beta 高不等于一定赚钱,也不等于绝对波动率一定高。它回答的是相对于所选市场基准的系统性风险。基准换成 SPY、沪深 300 或行业指数,结果可能不同,计算前也要确保两组日收益按日期对齐。
下面用 numpy 和 pandas 各算一次 Beta。两种写法的结果应一致,正好用来检查数据是否对齐、缺失值是否已处理。
python
cov_matrix = np.cov(stock_returns, market_returns)
beta = cov_matrix[0, 1] / cov_matrix[1, 1]
python
beta = stock_returns.cov(market_returns) / market_returns.var()
Python 实现
环境准备
python
import warnings
warnings.filterwarnings('ignore')
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import akshare as ak
import statistics as stats
plt.rcParams['font.sans-serif'] = [
'PingFang SC', 'Microsoft YaHei', 'SimHei',
'Noto Sans CJK SC', 'WenQuanYi Micro Hei', 'DejaVu Sans',
]
plt.rcParams['axes.unicode_minus'] = False
TRADING_DAYS = 252
计算函数
python
def annualize_return(daily_returns: pd.Series) -> float:
"""日收益率 → 年化收益率(复利近似)"""
mean_daily = daily_returns.mean()
return (1 + mean_daily) ** TRADING_DAYS - 1
def annualize_volatility(daily_returns: pd.Series) -> float:
"""日收益率 → 年化波动率"""
daily_std = daily_returns.std()
return daily_std * np.sqrt(TRADING_DAYS)
def sharpe_ratio(daily_returns: pd.Series, rf_annual: float) -> float:
"""简化夏普:(年化收益 − 无风险利率) / 年化波动"""
ann_ret = annualize_return(daily_returns)
ann_vol = annualize_volatility(daily_returns)
excess = ann_ret - rf_annual
return excess / ann_vol
def beta_vs_market(stock: pd.Series, market: pd.Series) -> tuple[float, float]:
"""numpy 与 pandas 双算法计算 Beta,便于交叉验证"""
cov = np.cov(stock, market)
beta_numpy = cov[0, 1] / cov[1, 1]
beta_pandas = stock.cov(market) / market.var()
return beta_numpy, beta_pandas
annualize_return 和 annualize_volatility 都假设一年有 252 个交易日。这个假设适合教学和常规日频回测,但换市场或换频率时应相应调整。另一个小问题是 ann_vol 若接近 0,夏普会失去意义,实际项目里应加上防护。
下载行情并计算
python
import time
tickers = [
'AAPL', 'MSFT', 'GOOGL', 'AMZN', 'META',
'NVDA', 'TSLA', 'AMD', 'JPM',
'SPY', # 标普 500 ETF,作 Beta 基准
]
rf_annual = 0.04 # 无风险利率近似 4%
def fetch_us_close(symbol: str, start: str) -> pd.Series:
"""联网拉取单只美股前复权收盘价"""
df = ak.stock_us_daily(symbol=symbol, adjust='qfq')
if df is None or df.empty:
raise RuntimeError(f'{symbol} 未返回数据')
df['date'] = pd.to_datetime(df['date'])
close = df.set_index('date').sort_index()['close'].rename(symbol)
return close.loc[close.index >= start]
# 下载数据(间隔 1 秒避免限速)
frames = {}
for t in tickers:
frames[t] = fetch_us_close(t, start_date)
time.sleep(1)
prices = pd.DataFrame(frames).dropna()
returns = prices.pct_change().dropna()
这里的 dropna() 不是可有可无的清理步骤。后面算协方差和 Beta 时,股票与市场必须使用同一批交易日;如果缺失值被悄悄错位,数值看似能算出来,含义却已经变了。
风险收益一览
python
metrics = pd.DataFrame({
t: {
'年化收益': annualize_return(returns[t]),
'年化波动': annualize_volatility(returns[t]),
'Sharpe': sharpe_ratio(returns[t], rf_annual),
}
for t in tickers
}).T.rename_axis('股票')
# 格式化输出
summary = metrics.copy()
summary['年化收益'] = summary['年化收益'].map('{:.2%}'.format)
summary['年化波动'] = summary['年化波动'].map('{:.2%}'.format)
summary['Sharpe'] = summary['Sharpe'].map('{:.2f}'.format)
print(summary)
输出示例:
| 股票 | 年化收益 | 年化波动 | Sharpe |
|---|---|---|---|
| AAPL | 26.01% | 28.41% | 0.78 |
| TSLA | 47.06% | 60.77% | 0.71 |
| NVDA | 45.17% | 47.29% | 0.87 |
| SPY | 18.50% | 22.30% | 0.65 |
注:以上为代码逻辑示例,实际数值需在本机运行后确认。
TSLA 的年化收益示例高于 AAPL,但 Sharpe 未必更高,这正是前面"收益不能脱离波动"的具体体现。
风险---收益散点图
python
fig, ax = plt.subplots(figsize=(10, 7))
for ticker, row in metrics.iterrows():
vol = row['年化波动']
ret = row['年化收益']
ax.scatter(vol, ret, s=140, edgecolors='white', linewidths=1.0, label=ticker)
ax.annotate(ticker, (vol, ret), xytext=(6, 5),
textcoords='offset points', fontsize=9)
ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _pos: f'{x:.0%}'))
ax.yaxis.set_major_formatter(FuncFormatter(lambda x, _pos: f'{x:.0%}'))
ax.set_xlabel('年化波动率')
ax.set_ylabel('年化收益率')
ax.set_title('风险-收益散点图(10 只 · 约 3 年)')
ax.grid(True, linestyle='--', alpha=0.35)
ax.legend(loc='upper left', ncol=2)
plt.tight_layout()
plt.savefig('risk_return_scatter.png', dpi=150, bbox_inches='tight')
plt.show()
图上更靠左上通常更理想:同样收益下波动更低,或同样波动下收益更高。但这只是样本期内的相对位置,不能据此直接得出未来配置结论;SPY 在这里更适合作为基准参考点。
Beta 的双重计算
python
MARKET = returns['SPY']
STOCKS = [t for t in tickers if t != 'SPY']
beta_rows = []
for ticker in STOCKS:
stock = returns[ticker]
b_np, b_pd = beta_vs_market(stock, MARKET)
beta_rows.append({
'股票': ticker,
'Beta(numpy)': b_np,
'Beta(pandas)': b_pd,
})
beta_df = pd.DataFrame(beta_rows).set_index('股票')
print(beta_df.map('{:.2f}'.format))
输出示例:
| 股票 | Beta(numpy) | Beta(pandas) |
|---|---|---|
| AAPL | 1.05 | 1.05 |
| TSLA | 1.35 | 1.35 |
| NVDA | 1.28 | 1.28 |
| JPM | 0.85 | 0.85 |
从示例看,TSLA、NVDA 的 Beta 大于 1,JPM 小于 1。重点不是给它们贴上"激进"或"稳健"的永久标签,而是确认:在当前样本和 SPY 这个基准下,它们与市场的联动强度不同。
用 statistics 看一眼日收益分布
python
aapl_daily = returns['AAPL'].dropna().tolist()
print(f'均值: {stats.mean(aapl_daily):>8.4%}')
print(f'中位数: {stats.median(aapl_daily):>8.4%}')
print(f'标准差: {stats.pstdev(aapl_daily):>8.4%}')
print(f'最大单日涨: {max(aapl_daily):>8.2%}')
print(f'最大单日跌: {min(aapl_daily):>8.2%}')
均值和中位数若差得较多,或者极端单日涨跌很显眼,就提醒我不要只依赖一个均值和一个夏普值来描述整段历史。
练习:用同一份数据多问一步
Sharpe 排序
python
sharpe_sorted = metrics['Sharpe'].sort_values(ascending=False)
for i, (t, v) in enumerate(sharpe_sorted.head(3).items(), 1):
print(f'第{i}名: {t} ({v:.2f})')
比较 JPM 与 NVDA 的 Beta
python
jpm_beta, _ = beta_vs_market(returns['JPM'], MARKET)
nvda_beta, _ = beta_vs_market(returns['NVDA'], MARKET)
print(f'JPM Beta: {jpm_beta:.2f}')
print(f'NVDA Beta: {nvda_beta:.2f}')
最后留一个不能被代码直接回答的问题:Sharpe 高是否意味着未来一定更好?答案仍然是否定的。过拟合、样本期偏短和市场环境切换,都可能让历史排序失效。
本节留下的东西
夏普比率让我不再只用年化收益评价结果,Beta 则补充了标的相对市场的敏感度。两者都依赖历史样本,因此更适合拿来比较、复核和追问,而不是当成预测器。
下一节会把视角从"波动有多大"推进到"净值曾从高点跌了多深",也就是最大回撤与仓位管理。
参考资源
- 课程仓库:datawhalechina/quant-for-beginners
- 本章 Notebook:02_夏普比率与Beta.ipynb
- 学习代码:
02_夏普比率与Beta.py(包含 5 个实验函数,可直接运行)
本文仅供学习交流,不构成投资建议。市场有风险,投资需谨慎。