单元 09:加入风险管理
单元 08 选出的 3 日移动平均策略,在前 240 个观测中表现很好,随后却在样本外失效。风险管理不能把失效的规律重新变成有效规律,但可以控制账户为了这条判断承担多少风险。
本单元不修改方向信号。我们会把“是否持有”与“持有多少”拆开,依次加入固定比例仓位、波动率目标、最大仓位、最小调仓阈值和止损,再用相同日期、相同价格和相同成本比较结果。
本单元目标
完成本单元后,你将能够:
- 区分方向信号与仓位规模;
- 比较全仓和固定比例仓位的收益、波动与回撤;
- 使用过去 20 个收益估计波动率,并据此调整目标仓位;
- 使用最大仓位限制杠杆和风险暴露;
- 使用最小调仓阈值减少不必要的微小交易;
- 比较平均现金比例、换手、成本和最大回撤;
- 实现时间关系明确的止损,并解释它可能锁定亏损、错过反弹;
- 在同一成本口径下解读风险调整后表现。
本单元依次运行:
uv run python examples/unit08/analyze_overfitting.py
uv run python examples/unit09/manage_risk.py第一条命令生成固定种子的虚构价格,第二条命令运行风险管理实验。
前置条件
- 已完成单元 08;
- 能解释方向信号、样本内外、交易成本、波动率和最大回撤;
- 已执行
uv sync --locked; - 后续命令均在项目根目录运行。
场景:方向正确,不等于应该全仓
方向信号回答“策略此时希望持有还是空仓”,仓位管理(Position Sizing)回答“如果持有,应该投入账户的多少比例”。两者是不同问题。
本例仍使用 3 日移动平均规则。收盘后形成的信号延迟一个时点,得到基础方向仓位
:该收益区间希望持有资产; :该收益区间希望持有现金。
全仓方案直接令
其中
两种方案都按相同公式扣除成本:
其中:
:第 个时点扣费后的策略简单收益率,无单位; :同一时点的资产简单收益率,无单位; :相邻仓位之差的绝对值,本教程称为单边换手; :单边成本率,本例为 0.1%。
| 方案 | 总收益 | 年化波动率 | 最大回撤 | 平均仓位 | 平均现金比例 |
|---|---|---|---|---|---|
| 全仓信号 | 17.6454% | 6.2827% | -19.9944% | 53.3333% | 46.6667% |
| 固定 50% | 8.5416% | 3.1413% | -10.5272% | 26.6667% | 73.3333% |
固定仓位把每期资产收益和交易成本都缩小一半,因此年化波动率恰好减半。总收益和最大回撤经过复利与路径计算,不会机械地恰好减半。
这里的“平均现金比例”是每个时点未投资比例的平均值,即
用过去波动调整仓位
固定 50% 简单清楚,却不会区分平静期和剧烈波动期。波动率目标(Volatility Targeting)尝试让观察到的风险较高时少持有,风险较低时多持有。
程序先用过去 20 个收益计算样本标准差,并按 252 个时点年化:
其中:
:在第 个时点决定仓位时可知的年化波动率估计; :此前 20 个时点的资产简单收益率; :样本标准差; :一年虚构交易时点数的近似,仅用于延续日频研究口径。
当日收益直到收盘后才完整可知,所以代码把滚动波动率再延迟一个时点:
observed_volatility = asset_return.rolling(20).std() * math.sqrt(252)
lagged_volatility = observed_volatility.shift(1)本例把目标年化波动率设为 6%,最大仓位设为 80%:
如果观察波动率是 12%,原始缩放比例为
比例超过 1 意味着需要杠杆(Leverage),即资产暴露超过账户自有资金。本单元不借入资金,80% 上限同时保留至少 20% 现金。风险预算(Risk Budget)的直觉就是:先决定愿意让某项风险占多大,再反推允许投入的资金规模。
目标不是保证
6% 是根据过去波动率计算仓位时使用的目标,不是未来组合波动率保证。策略本身会空仓,仓位被限制在 80%,未来波动也可能突然变化,所以最终实现的年化波动率是 4.3479%,而不是恰好 6%。
波动率窗口最开始没有 20 个完整收益,程序保持现金。这是风险估计的预热期,不代表方向信号主动看空。
小变化不一定值得交易
滚动波动率每天都可能变化,如果每个微小变化都调整仓位,就会产生更多换手。程序加入 10% 的最小调仓阈值(Rebalance Threshold):
if abs(desired_position - previous_position) >= 0.1:
current_position = desired_position
else:
current_position = previous_position有一个例外:当方向信号变为 0 时始终清仓。不能因为当前仓位小于阈值,就让本应离场的头寸继续留在账户中。
| 波动率仓位方案 | 总收益 | 累计单边换手 | 各期成本率合计 |
|---|---|---|---|
| 每次变化都调仓 | 11.2880% | 73.9811 | 7.3981% |
| 忽略小于 10% 的变化 | 11.3215% | 73.3151 | 7.3315% |
阈值只把换手降低了 0.6660。原因是这条 3 日均线策略的大部分交易来自 0 与持仓之间的方向切换,而不是相邻两天的仓位微调。调仓阈值确实生效,却不是解决高换手的万能办法。
“各期成本率合计”是每期 换手 × 0.1% 的直接相加,用于观察交易强度;它不是账户最终收益与毛收益的简单差,因为每期扣费后还会继续复利。
在同一口径下比较风险与收益
程序对五种方案使用相同的 360 个观测、0.1% 单边成本率、现金收益和年化期数:
| 方案 | 总收益 | 年化波动率 | 最大回撤 | 夏普比率 | 平均现金比例 | 累计换手 |
|---|---|---|---|---|---|---|
| 全仓信号 | 17.6454% | 6.2827% | -19.9944% | 1.8474 | 46.6667% | 103.0000 |
| 固定 50% | 8.5416% | 3.1413% | -10.5272% | 1.8474 | 73.3333% | 51.5000 |
| 波动率仓位,无调仓阈值 | 11.2880% | 4.3505% | -13.8739% | 1.7476 | 62.7055% | 73.9811 |
| 波动率仓位,10% 阈值 | 11.3215% | 4.3479% | -13.8458% | 1.7535 | 62.9094% | 73.3151 |
| 波动率仓位,加止损 | 12.8236% | 4.2709% | -12.6832% | 2.0047 | 63.1331% | 71.7049 |
固定 50% 与全仓方案的夏普比率完全相同,不是计算错误。在本教学简化下,仓位和成本都按同一常数缩放,平均收益与标准差一起减半,两者的比值不变。现实中加入最低佣金、现金收益、融资成本或非线性市场冲击后,比例通常不会如此整齐。
波动率仓位降低了绝对波动和回撤,却没有提高本样例的夏普比率。风险管理首先改变的是风险规模与亏损路径,不保证同时提高总收益或风险调整后指标。
止损只能在信息出现后行动
本例的止损(Stop Loss)规则为:从一次持仓开始累计标的收益,收盘后若跌幅达到 1.5%,下一时点退出,并暂停持仓 5 个时点。暂停期结束后,只有方向和波动率仓位仍允许持有时才重新进入。
2024-02-12 收盘后,累计跌幅达到 -2.1634%,越过 -1.5% 阈值。程序不能假装按 -1.5% 精确成交:当天亏损已经发生,仓位从下一时点才变为 0。五个暂停时点中,原策略只有两个时点原本会持仓,因此统计为两个“被迫空仓”时点。
在这条虚构路径里,两个时点随后继续下跌,所以止损方案的总收益从 11.3215% 提高到 12.8236%,最大回撤从 -13.8458% 收窄到 -12.6832%。这只是该路径的结果,不证明止损天然创造收益。
一条专门展示副作用的反弹路径
程序还构造了六个时点的极小反例。账户保持 50% 仓位,标的先跌 2%,紧接着上涨 4% 和 2%。止损在第一个下跌时点收盘后触发,从下一时点开始暂停,恰好错过反弹:
| 方案 | 扣费后总收益 |
|---|---|
| 不止损,继续持有 50% | 1.4286% |
| 触发止损并暂停 5 个时点 | -1.0995% |
止损没有撤销触发前的亏损,只改变了后续是否继续持有。它可能避开进一步下跌,也可能锁定亏损并错过恢复。
止损不能消除跳空与模型风险
日线收盘止损只能在观察到收盘价后决定下一步。真实市场可能直接跳过止损价,停牌或流动性不足也可能让订单无法及时成交。止损不会修复错误数据、未来函数、失效信号或过度拟合的模型。
止损阈值和暂停长度本身也是参数。如果查看结果后不断调到最漂亮的组合,就会重新落入单元 08 的数据窥探问题。
运行并检查产物
执行:
uv run python examples/unit09/manage_risk.py核心输出包括:
strategy total_return annualized_volatility max_drawdown sharpe_ratio
full 17.6454% 6.2827% -19.9944% 1.8474
fixed_50 8.5416% 3.1413% -10.5272% 1.8474
volatility_no_threshold 11.2880% 4.3505% -13.8739% 1.7476
volatility_managed 11.3215% 4.3479% -13.8458% 1.7535
volatility_with_stop 12.8236% 4.2709% -12.6832% 2.0047
止损触发: 1 次
止损后被迫空仓: 2 个时点
反弹反例(不止损 / 止损): 1.4286% / -1.0995%程序生成:
outputs/unit09/risk_comparison.csv:五种方案的同口径指标;outputs/unit09/risk_management_daily.csv:信号、波动率、仓位、收益、净值和回撤;outputs/unit09/stop_events.csv:止损触发日期、价格、跌幅和暂停长度;outputs/unit09/stop_counterexample.csv:先跌后反弹的止损反例;outputs/unit09/risk_management.png:仓位、净值和回撤对照图。
图中全仓方案承担最大的收益波动和回撤;固定 50% 把路径压缩;波动率方案在观察风险较高时降低仓位。止损线大部分时间与波动率方案重合,只在触发后的暂停期分离。
常见陷阱
把方向信号当成仓位大小
long 只表达希望持有,不代表必须投入 100%。方向规则、仓位规则、账户约束和订单执行应分别记录,才能知道结果变化来自哪里。
使用当日收益控制当日仓位
当日波动率只有当日收益完成后才知道。用包含
把历史波动率当成未来上限
滚动标准差只能描述过去窗口。市场进入新状态或发生跳空时,未来损失可能远超根据历史波动计算的仓位预算。
只看回撤变小,不看现金增加
更多现金通常会机械地降低波动和回撤,也会减少上涨期参与程度。比较方案时应同时报告平均仓位或现金比例,避免把更少承担风险误写成预测能力提高。
用更多风控规则掩盖失效策略
仓位上限、波动率目标和止损都不能证明基础信号有优势。规则越多,还会增加参数数量、交易复杂度和过拟合机会。
验证结果
完成标准:
- 全仓、固定 50%、两种波动率仓位和止损方案使用相同数据与成本口径;
- 固定 50% 仓位把年化波动率从 6.2827% 降至 3.1413%;
- 波动率仓位始终位于 0~80%,并只使用决定仓位前已经可知的收益;
- 10% 调仓阈值减少了微小调整,但方向信号离场时仍然清仓;
- 止损在触发后的下一时点行动,并执行 5 个时点的暂停期;
- 默认路径和反弹反例同时保留,能够说明止损效果依赖后续路径;
- 能解释风险下降为什么不能证明基础信号重新有效。
小结
本单元把风险管理放在方向信号之后、收益计算之前:
- 固定 50% 仓位降低了绝对收益、波动和回撤,但没有提高夏普比率;
- 波动率目标根据过去风险调整仓位,并用 80% 上限避免杠杆;
- 10% 调仓阈值只小幅减少换手,因为主要交易来自方向切换;
- 止损在默认路径中避开后续损失,却在反弹路径中锁定亏损并错过恢复;
- 所有方案使用相同成本和时间口径,并同时报告现金、换手和回撤。
风险管理的目标不是让每个回测更赚钱,而是在明确假设下控制账户愿意承担的暴露与损失路径。历史风险更低,也不代表未来不会出现更大的损失。
练习
基础练习:调整最大仓位
把最大仓位从 80% 改为 50%,比较它与固定 50% 仓位在平静期的差别。
进阶练习:改变风险与调仓参数
把目标波动率分别设为 4% 和 8%,观察最大仓位上限在哪些日期生效。再将最小调仓阈值改为 5% 和 20%,同时记录换手、成本和跟踪目标仓位的偏差。
延伸练习:改变止损暂停期
把止损暂停期从 5 个时点改为 2 和 10,分别在默认路径与反弹路径中比较结果。