Datawhale 量化入门笔记:夏普比率和Beta

先有收益率,为什么还不够?

前面做回测时,很自然会先看年化收益。可把两只标的放在一起比较,问题就出来了:一只年化收益 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 或行业指数,结果可能不同,计算前也要确保两组日收益按日期对齐。

下面用 numpypandas 各算一次 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_returnannualize_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 则补充了标的相对市场的敏感度。两者都依赖历史样本,因此更适合拿来比较、复核和追问,而不是当成预测器。

下一节会把视角从"波动有多大"推进到"净值曾从高点跌了多深",也就是最大回撤与仓位管理。

参考资源


本文仅供学习交流,不构成投资建议。市场有风险,投资需谨慎。

相关推荐
Daisy Lee1 个月前
量化学习-第1章-什么是量化金融
学习·金融·datawhale
IRevers1 个月前
【大模型】Gemma4在ROCm和vLLM部署
人工智能·pytorch·深度学习·大模型·datawhale·vllm·amdev
安如衫1 个月前
【Hello-ROCm】vLLM 跑通 Gemma4-E4B
datawhale·vllm·amdev
yzqy_2 个月前
AMD AI 开发者计划学习笔记:从 ROCm 到 Ryzen AI,理解 AMD 的 AI 开发生态
人工智能·笔记·学习·datawhale·amdev
10WTW012 个月前
AMD Hello-ROCm 环境配置(一)
datawhale·gemma4·amdev
尘汐筠竹2 个月前
Day1-2 学习笔记:在 AMD 云环境上部署 Gemma 4 大模型
笔记·学习·datawhale·amdev
Mocode2 个月前
【2026】Datawhale X AMD · Hello ROCm - Part1 - 配置云环境&部署大模型
datawhale·amdev
heart_66622 个月前
AMD平台实战:ModelScope 一键微调 Gemma 4 情绪分类实战
大数据·人工智能·datawhale·amdev