只看 Sharpe,还是会漏掉一段很难熬的过程
一个策略历史 Sharpe=1.8,乍看不错;但如果中间最大回撤是 −40%,结果又是另一回事。净值从高点跌下来时,人真正面对的不是年化收益,而是"这次下跌还要多久、自己是否能按原计划继续持有"。
这一节把"最惨的时候亏过多少"单独量化出来,再用半仓和满仓的净值曲线对照。这样看策略时,除了终点在哪里,也开始看中间走得有多难受。
最大回撤不是最大单日跌幅
回撤是当前净值相对历史最高净值的跌幅:
Drawdown t = Equity t Peak t − 1 \text{Drawdown}_t = \frac{\text{Equity}_t}{\text{Peak}_t} - 1 Drawdownt=PeaktEquityt−1
其中 Peak t = max s ≤ t Equity s \text{Peak}t = \max{s \leq t} \text{Equity}_s Peakt=maxs≤tEquitys,表示截至 t t t 时刻出现过的净值高点。最大回撤就是整段回撤序列中最小的那个值:
MDD = min t Drawdown t \text{MDD} = \min_t \text{Drawdown}_t MDD=tminDrawdownt
它的起点是一个历史高点,终点是其后的低点,所以不能简单等同于"最大单日跌幅"。同样为 −20% 的最大回撤,一种情况可能一个月就修复,另一种情况可能两年都在水下。数值一样,持有体验并不一样。
python
def compute_drawdown(equity: pd.Series) -> pd.Series:
"""回撤序列 = 当前净值 / 历史最高净值 − 1(≤ 0)。"""
running_peak = equity.cummax()
return equity / running_peak - 1
def max_drawdown(equity: pd.Series) -> float:
"""最大回撤 = 回撤序列中的最小值(最深的那次坑)。"""
return compute_drawdown(equity).min()
python
prices = fetch_us_close('AAPL', 3) # 近 3 年 AAPL 收盘价
returns = prices.pct_change().dropna()
equity = buy_hold_equity(returns) # 买入持有净值曲线
max_dd = max_drawdown(equity)
print(f'AAPL 最大回撤: {max_dd:.2%}') # 例如:−26.3%
这里的输入应是按时间顺序排列的净值序列。若日期没有排序,cummax() 得到的"历史高点"就不再是历史上的高点,回撤计算会失真。
水下曲线:除了深度,还要看恢复期
把每日回撤画成填充图,就是水下曲线。上图的净值和历史高点用来定位回撤来源,下图的面积更适合观察回撤持续时间。
python
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 7), sharex=True,
gridspec_kw={'height_ratios': [2, 1]})
# 上图:净值 + 历史最高
ax1.plot(equity.index, equity, label='买入持有净值')
ax1.plot(running_peak.index, running_peak, '--', alpha=0.55, label='历史最高')
# 下图:回撤填充
ax2.fill_between(drawdown.index, drawdown, 0, alpha=0.45)
ax2.axhline(max_dd, ':', label=f'最大回撤 {max_dd:.1%}')
读这张图时,我会先看最深处确认最大回撤,再看填充区域有多宽。窄而浅的回撤通常恢复得更快;宽而深的区域则说明净值长期未创新高。最大回撤本身没有告诉我们恢复时间,所以水下曲线正好补上这一部分。
仓位改变的是净值曲线的尺度
仓位管理不是"预测要跌就空仓",它先回答的是:信号出现时,账户中有多少资金参与。对同一条日收益序列,半仓会把每天的收益和损失都缩小一半;长期复利下,最大回撤不一定严格减半,但通常会明显下降。
| 仓位 | 含义 | 直观影响 |
|---|---|---|
| 满仓 100% | 全部资金跟着策略 | 收益与回撤都完整承受 |
| 半仓 50% | 一半资金参与 | 每日涨跌幅按比例缩小 |
下面用双均线策略的收益列,分别构造满仓、半仓和买入持有的净值:
python
MA_SHORT, MA_LONG = 5, 20
HALF_SIZE = 0.5
df['Signal'] = (df['MA_S'] > df['MA_L']).astype(float)
df['StratRet'] = df['Signal'].shift(1) * df['Return']
df['Equity_100'] = buy_hold_equity(df['StratRet']) # 满仓
df['Equity_50'] = buy_hold_equity(df['StratRet'] * 0.5) # 半仓
df['Equity_BH'] = buy_hold_equity(df['Return']) # 买入持有
结果示例:
| 策略 | 最大回撤 |
|---|---|
| 双均线 · 满仓 | −18.5% |
| 双均线 · 半仓 | −9.2% |
| 买入持有 | −26.3% |
python
fig, ax = plt.subplots(figsize=(12, 5))
for col, kw in style.items():
ax.plot(df.index, df[col], **kw)
ax.legend(loc='upper left')
ax.set_ylabel('净值')
ax.set_xlabel('日期')
这组示例不是在证明"半仓更优"。半仓减少了回撤,也会放弃一部分上涨;是否合适取决于可承受的风险预算和策略本身。要是只比较最终净值,很容易忽略这个取舍。
本节的检查清单
最大回撤计算时先确认净值按日期排序;看图时把深度与持续时间分开;比较仓位时,不能只看最终收益。下面几条公式把本节用到的计算集中在一起:
| 公式 | 含义 |
|---|---|
| Drawdown t = Equity t Peak t − 1 \text{Drawdown}_t = \frac{\text{Equity}_t}{\text{Peak}_t} - 1 Drawdownt=PeaktEquityt−1 | 回撤 |
| MDD = min t Drawdown t \text{MDD} = \min_t \text{Drawdown}_t MDD=mintDrawdownt | 最大回撤 |
| Equity t = ∏ ( 1 + r t ) \text{Equity}_t = \prod (1 + r_t) Equityt=∏(1+rt) | 买入持有净值 |
| StratRet = Signal t − 1 × r t \text{StratRet} = \text{Signal}_{t-1} \times r_t StratRet=Signalt−1×rt | 策略收益 |
本节解决的是:一条策略在样本期里曾经从高点跌多深,以及同一策略在不同仓位下呈现怎样的风险尺度。还没有解决的是,怎样根据目标波动率、交易成本和再平衡规则确定仓位;这需要在更完整的回测框架里继续验证。
下一章将从单一标的转向组合,看看相关性为什么会影响分散效果。
参考资源
- 课程仓库:datawhalechina/quant-for-beginners
- 本章 Notebook:03_最大回撤与仓位管理.ipynb
- 学习代码:
03_最大回撤与仓位管理.py(包含 5 个实验函数,可直接运行)