上一篇我们把数据洗干净,分别存进了 CSV、SQLite 和 Parquet。但存起来只是「备战」,数据本身不会告诉你任何结论——「平安银行五年从 16 块涨到 17 块」这句话信息量几乎为零。量化研究真正的基本语言是两个词:收益率和波动率。收益率回答「赚了多少」,波动率回答「路上颠不颠」。这俩概念是后面所有策略、回测、风控的地基,所以本篇值得你学得慢一点。
为什么不能直接看价格?因为不同股票价格量级不同:一只 150 元的股票涨 3 元和一只 15 元的股票涨 3 元完全不是一回事,前者才 2%,后者是 20%。只有换成收益率,两只股票才站在同一把尺子上比较;也只有波动率,才能把「赚钱路上的颠簸程度」变成一个可比的数字。
本篇目标
学完本篇,你能够做到:
- 用
pct_change计算简单收益率,用np.log配合diff计算对数收益率,并说出两者的公式区别与适用场景。 - 从日收益率算出区间累计收益和资金曲线(净值曲线)。
- 用两个公式(复利式与简化式)把日收益年化,并解释年化的前提假设。
- 计算历史波动率:日收益率标准差乘以 √252 得到年化波动率。
- 计算回撤序列并找到最大回撤,为第 19 篇的完整指标体系打头阵。
核心概念
简单收益率:你钱包的真实变化
简单收益率(Simple Return)的公式:
R_t = P_t / P_{t-1} - 1
其中 P_t 是今天收盘价,P_{t-1} 是昨天收盘价。白话版:今天的钱除以昨天的钱再减一,就是你今天赚(亏)了百分之几。你账户盈亏就是按它结算的,所以它最直观、最贴近体感。它有个麻烦:跨时间不可加。今天 +10%、明天 -10%,直觉是打平,实际是 1.10 × 0.90 = 0.99,亏了 1%。所以把多日收益串起来必须用连乘,不能直接相加。
对数收益率:数学家的最爱
对数收益率(Log Return)的公式:
r_t = ln(P_t / P_{t-1}) = ln(P_t) - ln(P_t-1)
白话版:把价格取自然对数,再看相邻两天差多少。它最大的优点是跨时间可加:一年的对数收益,恰好等于这一年里每天对数收益直接相加。推导只需一行:ln(P_T/P_0) = ln(P_T) - ln(P_0),中间项全部相消。另外在涨跌都很小的日常行情里,两者数值几乎一样(+1% 的简单收益约等于 +0.995% 的对数收益),波动越大差别越明显。
何时用哪个:看涨跌、画资金曲线、向别人汇报「今年赚了多少」——用简单收益率;跨期加总、统计建模、算波动率——用对数收益率更方便。本系列后面篇幅里,展示涨跌用简单收益率,统计计算偏对数收益率,遇到交叉处会再提醒。
划重点:简单收益率是「钱的真实比例」,对数收益率是「价格对数坐标上的步长」。记住「跨期相加用对数,连乘还原用简单」这一句,几乎能应付所有场景:
r = ln(1+R)与R = exp(r) - 1随时互转。
累计收益与年化:把每天串成一条线
把每日简单收益率加一后连乘再减一,就是区间累计收益:累计收益 = P_T / P_0 - 1。中途每个时点的连乘积 (1+R_1)(1+R_2)...(1+R_t) 叫净值(Net Value,起点为 1),画出来就是资金曲线——它才是「如果第一天投 1 块钱,现在变成多少」。
年化收益率(Annualized Return)把任意长度的区间收益折算成「一年的速度」,便于比较不同长度的策略。复利式公式:
年化收益 = (1 + 累计收益) ^ (252 / N) - 1
N 是区间内的交易日个数,252 是一年交易日的通用约定数。白话版:这 N 天一共赚了这么多,按复利匀速折算,一年能滚到多少。另有简化式「日平均收益 × 252」,它不考虑复利,只在收益很小、区间很短时近似可用;严谨比较请用复利式。注意前提:年化是外推假设——过去五年的匀速不等于未来一年的速度,这是教学约定,不是预测。
历史波动率:颠簸程度的标尺
历史波动率(Historical Volatility)用日收益率的标准差衡量每天上下晃动的幅度,再乘以 √252 折算成年度:
日波动率 = std(日收益率)
年化波动率 = 日波动率 × √252
为什么开根号?白话版:如果每天的波动相互独立,n 天的方差是单日方差的 n 倍,而标准差是方差的平方根,所以 n 天的标准差是单日的 √n 倍。「乘 √252」就是把这个平方根法则用到一年上。波动率 20% 的含义:按正态近似,一年下来价格大约有三分之二的时间落在「期望值 ±20%」的范围内——它越大,资金曲线越颠簸,这是风险的第一个近似刻度(更完整的风险指标在第 19 篇展开)。
回撤:从山顶跌下来的深度
回撤(Drawdown)衡量某个时刻的净值比历史最高点低了百分之多少:回撤 = 净值 / 净值历史最高 - 1。它永远是负数或零,最惨的那一次就叫最大回撤(Maximum Drawdown)。先记住一句话:回撤回答「拿着这个策略,最难熬的时候账面亏多少」。本篇只做初步计算,完整讨论留给第 19 篇。
完整代码
本篇只依赖 pandas 和 numpy(画图用 matplotlib)。安装命令:
uv pip install pandas numpy matplotlib
数据沿用第 8 篇存下的本地文件,并保留三级 fallback:优先读 Parquet,其次读 CSV,都没有就本地造一份同结构的模拟数据——保证你复制就能跑。第一段:取数并计算两种收益率。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["SimHei"] # 中文显示,第 6 篇讲过
plt.rcParams["axes.unicode_minus"] = False
def load_data() -> pd.DataFrame:
"""三级 fallback:Parquet → CSV → 本地造数据(与第 8 篇数据结构一致)。"""
try: # 第一选择:第 8 篇存下的 Parquet
df = pd.read_parquet("000001_clean.parquet")
print("已读取 000001_clean.parquet")
except Exception:
try: # 第二选择:第 8 篇存下的 CSV
df = pd.read_csv("000001_clean.csv", index_col="date", parse_dates=["date"])
print("已读取 000001_clean.csv")
except Exception: # 兜底:本地造 2020-2024 模拟行情
print("未找到第 8 篇的数据文件,使用本地模拟数据")
rng = np.random.default_rng(1)
n = 1215
idx = pd.bdate_range("2020-01-02", periods=n)
close = 16 * np.exp(np.cumsum(rng.normal(0.0002, 0.02, n)))
df = pd.DataFrame({"close": close}, index=idx)
df.index.name = "date"
return df[["close"]].dropna()
df = load_data()
df["simple_ret"] = df["close"].pct_change() # 简单收益率
df["log_ret"] = np.log(df["close"] / df["close"].shift(1)) # 对数收益率
print(df[["simple_ret", "log_ret"]].head())
print("两列相关系数:", df["simple_ret"].corr(df["log_ret"]).round(6))
print("单日最大差异:", (df["simple_ret"] - df["log_ret"]).abs().max().round(6))
第二段:累计收益、年化、波动率与回撤,最后画资金曲线加回撤区域。
# ---- 累计收益与净值曲线 ----
df["nav"] = (1 + df["simple_ret"]).cumprod() # 净值,起点约为 1(首日收益为 NaN)
total_ret = df["close"].iloc[-1] / df["close"].iloc[0] - 1
print(f"区间累计收益:{total_ret:.2%}")
# ---- 年化收益:复利式(用复利式做对比结论)----
n_days = df["simple_ret"].notna().sum() # 有效交易日数
annual_ret = (1 + total_ret) ** (252 / n_days) - 1
print(f"复利式年化收益:{annual_ret:.2%}")
print(f"简化式(日均×252):{df['simple_ret'].mean() * 252:.2%}")
# ---- 历史波动率:日标准差 × √252 ----
daily_vol = df["simple_ret"].std()
annual_vol = daily_vol * np.sqrt(252)
df["vol_20d"] = df["simple_ret"].rolling(20).std() * np.sqrt(252) # 20日滚动年化波动率
print(f"日波动率:{daily_vol:.4f},年化波动率:{annual_vol:.2%}")
# ---- 回撤序列与最大回撤 ----
df["drawdown"] = df["nav"] / df["nav"].cummax() - 1 # 相对历史最高净值的跌幅
print(f"最大回撤:{df['drawdown'].min():.2%}")
# ---- 可视化:净值曲线 + 回撤阴影 ----
fig, axes = plt.subplots(2, 1, figsize=(11, 7), sharex=True,
gridspec_kw={"height_ratios": [2, 1]})
axes[0].plot(df.index, df["nav"], color="#1f77b4", label="净值曲线")
axes[0].set_title("资金曲线(起点净值为 1)")
axes[0].legend()
axes[0].grid(True, alpha=0.3)
axes[1].fill_between(df.index, df["drawdown"], 0, color="#d62728", alpha=0.4)
axes[1].set_title("回撤(负值区域 = 低于历史高点的幅度)")
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
进阶一步:给账户算一份 60 日滚动风险体检。 离开本篇前,我们把前面算好的指标「组合」一次,体会量化研究里指标叠加的用法:用 60 日窗口滚动计算年化波动率和「日均值除以日波动率再乘 √252」的滚动夏普比(Sharpe Ratio,夏普比率,衡量每承受一单位波动换来多少超额收益,本系列统一用无风险利率年化 2% 的简化版本,第 19 篇会正式展开):
# ---- 60 日滚动风险体检:滚动波动率与滚动夏普 ----
rf_daily = 0.02 / 252 # 无风险利率折算到日(系列口径:年化 2%)
roll_vol = df["simple_ret"].rolling(60).std() * np.sqrt(252)
roll_sharpe = (df["simple_ret"] - rf_daily).rolling(60).mean() \
/ df["simple_ret"].rolling(60).std() * np.sqrt(252)
peak_vol_idx = roll_vol.idxmax() # 波动率最高的那一天
print(f"五年里 60 日滚动波动率的最高点出现在 {peak_vol_idx.date()},"
f"年化 {roll_vol.max():.1%}")
print(f"期末 60 日滚动夏普:{roll_sharpe.iloc[-1]:.2f}")
fig, ax = plt.subplots(figsize=(11, 4))
ax.plot(df.index, roll_vol, color="#d62728", label="60 日滚动年化波动率")
ax.plot(df.index, roll_sharpe, color="#1f77b4", label="60 日滚动夏普")
ax.axhline(0, color="gray", linewidth=0.8)
ax.set_title("滚动风险体检(波动率高企常对应夏普走低)")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
这段没有新公式,只是把第 5 篇的 rolling 和本篇的波动率、夏普组装到一起。你会看到两条曲线经常「反着走」:市场恐慌、波动率飙升的时段,往往正是夏普比率被拖向零甚至为负的时段——「收益的代价是波动」在这张图上不再是抽象话。示例输出示意:
五年里 60 日滚动波动率的最高点出现在 2024-02-07,年化 41.3%
期末 60 日滚动夏普:-0.18
代码逐段解释
第 1 段:取数与两种收益率。 load_data() 是第 8 篇存储的直接受益者:有 Parquet 读 Parquet(类型零损耗),有 CSV 读 CSV(记得 parse_dates),全没有就造一份——三级 fallback 保证教学代码永不断供。两种收益率的计算各占一行:pct_change() 内部就是「当前值除以上一值再减一」;对数收益率用 close / close.shift(1) 得到逐日比值再取 ln,shift(1) 是「把整列往下挪一格」,从而实现今天的自己减去昨天的自己。两者首行都是 NaN(第一天没有「昨天」),属于正常现象。打印的相关系数接近 1、单日最大差异在小数点后第三四位,直观印证「日常小幅波动下两者几乎相等」。示例输出示意(数值为示意,且与本地模拟数据的逻辑自洽):
已读取 000001_clean.parquet
simple_ret log_ret
date
2020-01-02 NaN NaN
2020-01-03 -0.0055 -0.0055
...
两列相关系数: 0.9999
单日最大差异: 0.0007
第 2 段:四个指标一条龙。 净值 (1+R).cumprod() 从 1 起步逐日复利;累计收益直接用首尾价格相除,两种算法结果一致(连乘定义就是这么来的)。年化给出两个公式的对照:复利式 (1+总收益)^(252/N)-1 与简化式 日均×252,你会看到两者数值接近但不相等——差别来自复利效应,区间越长差异越大,这就是为什么建议以复利式为准。波动率一行核心是 std() × np.sqrt(252);额外算了一个 20 日滚动年化波动率 vol_20d,让「波动率本身也在变化」这件事可见——平稳期它低,恐慌期它飙升。回撤两行:cummax() 是「到今天为止的历史最高净值」,净值除以它再减一得到回撤序列,min() 取最惨的那次。示例输出示意:
区间累计收益:-6.14%
复利式年化收益:-1.26%
简化式(日均×252):-1.19%
日波动率:0.0204,年化波动率:32.36%
最大回撤:-27.85%
划重点:上面示例输出里的全部数字只是「本地模拟数据的示意」,不是任何真实股票的真实业绩。请你跑自己的数据看自己的数字——这正是第 8 篇坚持「存本地数据」的意义:指标必须长在你自己的数据上。
关于画图。 上图是净值曲线,下图用 fill_between 把回撤画成红色阴影区:阴影越深,代表离历史高点跌得越远。两图共用时间轴后,你能直接对应「净值创新高 → 回撤归零;净值下跌 → 阴影加深」的节奏,这是感受波动率和回撤关系最直观的方式。
常见坑与小练习
坑 1:忘了处理首行 NaN 就去算指标。 现象:求和、画图时首日出现异常或报错。原因:pct_change 和 shift 产生的第一行没有前值。避免方式:计算指标时用 notna() 计数、画图时让 matplotlib 自动跳过,或明确 dropna() 后再统计,别用 fillna(0) 伪装成「首日不涨不跌」。
坑 2:把对数收益率直接当账户收益汇报。 现象:算出来「赚了 8%」,账户实际只有 7.7%。原因:对数收益要 exp(r) - 1 还原成简单收益才对应真实盈亏。避免方式:汇报和资金核算用简单收益,建模和加总用对数收益,别串门。
坑 3:年化波动率忘了开根号。 现象:年化数字小了几十倍,比如本该 30% 算成 2%。原因:把「乘 √252」写成了「乘 252 的 1/252」或直接没乘。避免方式:记牢公式 日标准差 × √252,用 np.sqrt(252) 让代码自解释。
坑 4:回撤用了未来函数。 现象:回测里的「历史最高净值」包含了今天之后的数据。原因:用全区间 max 而不是 cummax(截至当日累计最大)。避免方式:永远用 cummax;这也是第 17 篇要正式展开的未来函数问题的一个预演。
坑 5:拿涨跌幅列(%)和收益率小数混算。 现象:波动率突然大了 100 倍。原因:数据源给的 pct_chg 是 1.25 表示 1.25%,而 pct_change 算出的是 0.0125。避免方式:统一口径——本系列收益率一律用小数(0.0125),遇到百分数列先除以 100。
小练习 1: 把收益率按月重采样,计算每月收益率并用柱状图画出来(提示:对数收益直接 resample("ME").sum();简单收益要先转成对数、加总后再 exp 还原)。
思路提示:
monthly = df["log_ret"].resample("ME").sum(),画图用monthly.plot(kind="bar");对比一下两种口径的月度收益差异有多大。
小练习 2: 同时计算 20 日和 60 日滚动年化波动率,画在同一张图上,观察「短期波动率」和「长期波动率」谁更抖、谁更滞后。
思路提示:把本篇
vol_20d那一行复制改成 60,两列一起plot;想一想窗口越长曲线越平滑的原因。
小练习 3: 找出最大回撤发生的「山顶日期」和「谷底日期」,并计算从山顶到谷底用了多少个交易日、从谷底修复到前高又用了多少天。
思路提示:谷底是
df["drawdown"].idxmin();山顶是谷底之前最后一次净值创新高的日子,可以用谷底当天的nav.cummax()对应的日期定位。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。