Files
etf/rotation/experiments/task6_drawdown_analysis.py
aszerW 04b858ff09 feat: 添加ETF轮动策略诊断分析实验
新增6维度策略诊断实验脚本和报告:
- task1: 信号产生分析 (调仓频率、无效调仓率)
- task2: 收益计算分析 (T+1执行偏差、溢价问题)
- task3: 调仓逻辑分析 (最小持仓期模拟)
- task4: 资金管理分析 (止损、波动率适配)
- task5: 收益归因分析 (集中度、静态vs轮动)
- task6: 回撤诊断分析 (最大回撤复盘、尾部风险)

输出报告:
- diagnosis_report.md: 完整策略诊断报告
- rebalancing_optimization_experiment.md: 调仓频率优化实验报告

实验结论:
- 发现调仓过于频繁 (405次/1549天)
- No-Trade Region方案可提升年化3%、夏普0.11
- 但改善幅度有限,信号质量是根本瓶颈
2026-06-06 15:00:28 +08:00

290 lines
11 KiB
Python

"""
Task 6: 回撤诊断
分析维度:
6.1 最大回撤复盘 - 2022-05 前后持仓与动量变化
6.2 近期回撤趋势 - 2026 年回撤分析
6.3 极端尾部风险 - 极端日归因
"""
import sys
from pathlib import Path
from collections import defaultdict
from typing import Dict, List
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from rotation.experiments.common import (
load_nav, load_signals, load_detail_days, load_detail_meta,
print_section, compute_drawdown,
)
def analyze_max_drawdown(days: List[dict], nav: pd.DataFrame):
"""6.1 最大回撤复盘 (2022-05)"""
print_section("6.1 最大回撤复盘 (2022-04 ~ 2022-06)")
# 找到最大回撤的谷底
nav_df = nav.copy()
dd = compute_drawdown(nav_df['nav'])
trough_idx = dd.idxmin()
trough_date = nav_df.iloc[trough_idx]['date']
trough_dd = dd.iloc[trough_idx]
print(f" 最大回撤谷底: {trough_date.date()}")
print(f" 最大回撤: {trough_dd:.2%}")
# 找到回撤开始点(前高点)
peak_idx = nav_df['nav'][:trough_idx+1].idxmax()
peak_date = nav_df.iloc[peak_idx]['date']
peak_nav = nav_df.iloc[peak_idx]['nav']
print(f" 回撤起点(前高): {peak_date.date()}, NAV={peak_nav:.4f}")
print(f" 回撤持续: {(trough_date - peak_date).days}")
# 找到恢复点
recovery_mask = nav_df['nav'].iloc[trough_idx:] >= peak_nav
if recovery_mask.any():
recovery_idx = recovery_mask.idxmax()
recovery_date = nav_df.iloc[recovery_idx]['date']
print(f" 回撤恢复: {recovery_date.date()}, 恢复耗时: {(recovery_date - trough_date).days}")
else:
print(f" 回撤未恢复(截至数据结束)")
# 2022-04 ~ 2022-06 详细持仓变化
print(f"\n 2022-04 ~ 2022-06 持仓与动量变化:")
days_2022 = [d for d in days
if d['date'] >= '2022-04-01' and d['date'] <= '2022-06-30']
prev_holdings = []
key_events = []
for day in days_2022:
holdings = day.get('holdings', [])
is_rebal = day.get('is_rebalance', False)
if is_rebal:
added = set(holdings) - set(prev_holdings)
removed = set(prev_holdings) - set(holdings)
# 收集动量得分
momentums = {}
for code, asset in day.get('assets', {}).items():
m = asset.get('momentum')
if m is not None:
momentums[code] = m
key_events.append({
'date': day['date'],
'nav': day['nav'],
'daily_return': day['daily_return'],
'added': added,
'removed': removed,
'holdings': holdings,
'momentums': momentums,
})
prev_holdings = holdings
print(f" 期间调仓事件数: {len(key_events)}")
for evt in key_events:
mom_str = ', '.join(f"{c}={v:.4f}" for c, v in
sorted(evt['momentums'].items(), key=lambda x: -x[1])[:5])
print(f" {evt['date']}: NAV={evt['nav']:.4f}, "
f"日收益={evt['daily_return']:+.4%}")
print(f" 调仓: +{evt['added']} -{evt['removed']}")
print(f" 持仓: {evt['holdings']}")
print(f" Top5动量: {mom_str}")
# 分析为什么动态阈值没有触发防御
print(f"\n 动态阈值分析 (2022-04 ~ 2022-06):")
for day in days_2022:
assets = day.get('assets', {})
bond = assets.get('931862.CSI', {})
bond_m = bond.get('momentum')
threshold = bond.get('threshold', 0)
if bond_m is not None:
holdings = day.get('holdings', [])
has_bond = '931862.CSI' in holdings
# 只在调仓日或每周输出一次
if day.get('is_rebalance') or day['date'].endswith('-01') or day['date'].endswith('-15'):
non_bond_above = sum(1 for c, a in assets.items()
if c != '931862.CSI' and a.get('above_threshold', False))
print(f" {day['date']}: 短债动量={bond_m:.6f}, "
f"阈值={threshold:.6f}, 持仓={holdings}, "
f"非债券>阈值: {non_bond_above}")
return {'trough_date': str(trough_date.date()), 'max_dd': float(trough_dd)}
def analyze_recent_drawdown(days: List[dict], nav: pd.DataFrame):
"""6.2 2026 年近期回撤分析"""
print_section("6.2 2026 年近期回撤分析")
nav_2026 = nav[nav['date'].dt.year == 2026].copy()
if len(nav_2026) == 0:
print(" 无 2026 年数据")
return {}
dd_2026 = compute_drawdown(nav_2026['nav'])
max_dd_2026 = dd_2026.min()
trough_idx = dd_2026.idxmin()
trough_date = nav_2026.iloc[trough_idx - nav_2026.index[0]]['date'] if trough_idx >= nav_2026.index[0] else None
print(f" 2026 年最大回撤: {max_dd_2026:.2%}")
# 2026 年月度收益
nav_2026['month'] = nav_2026['date'].dt.month
print(f"\n 2026 年月度收益:")
for month, grp in nav_2026.groupby('month'):
m_ret = grp['nav'].iloc[-1] / grp['nav'].iloc[0] - 1
m_dd = compute_drawdown(grp['nav']).min()
print(f" {month}月: 收益={m_ret:+.2%}, 月内最大回撤={m_dd:.2%}")
# 2026 年极端日
extreme_2026 = nav_2026[(nav_2026['daily_return'] > 0.03) | (nav_2026['daily_return'] < -0.03)]
print(f"\n 2026 年极端日(|收益|>3%):")
days_2026_detail = [d for d in days if d['date'].startswith('2026')]
for _, row in extreme_2026.iterrows():
date_str = row['date'].strftime('%Y-%m-%d')
day_detail = None
for d in days_2026_detail:
if d['date'] == date_str:
day_detail = d
break
holdings_info = ""
if day_detail:
for code, asset in day_detail.get('assets', {}).items():
if asset.get('is_held'):
etf_ret = asset.get('etf_return_ctc', 0)
holdings_info += f" {code}({etf_ret:+.2%})"
print(f" {date_str}: {row['daily_return']:+.4%} |{holdings_info}")
# NAV 绝对回撤金额分析
print(f"\n 绝对回撤金额分析:")
initial_nav = nav['nav'].iloc[0]
current_nav = nav['nav'].iloc[-1]
peak_nav = nav['nav'].max()
print(f" 初始 NAV: {initial_nav:.4f}")
print(f" 当前 NAV: {current_nav:.4f}")
print(f" 峰值 NAV: {peak_nav:.4f}")
print(f" 峰值回撤绝对值: {peak_nav * abs(max_dd_2026):.4f} NAV 单位")
print(f" 对比: 2020 年全年收益 NAV = "
f"{nav[nav['date'].dt.year == 2020]['nav'].iloc[-1] - nav[nav['date'].dt.year == 2020]['nav'].iloc[0]:.4f}")
return {'max_dd_2026': float(max_dd_2026)}
def analyze_extreme_tail(days: List[dict], nav: pd.DataFrame):
"""6.3 极端尾部风险"""
print_section("6.3 极端尾部风险分析")
returns = nav['daily_return'].values
# 收益分布统计
print(" 日收益分布:")
for pct in [1, 5, 10, 25, 50, 75, 90, 95, 99]:
val = np.percentile(returns, pct)
print(f" P{pct}: {val:+.4%}")
# 尾部风险指标
tail_5pct = returns[returns <= np.percentile(returns, 5)]
print(f"\n 尾部风险 (5% 分位以下):")
print(f" CVaR(5%): {np.mean(tail_5pct):+.4%}")
print(f" Worst: {np.min(returns):+.4%}")
# 极端日归因
print(f"\n 极端亏损日归因 (日收益 < -5%):")
extreme_loss = nav[nav['daily_return'] < -0.05]
for _, row in extreme_loss.iterrows():
date_str = row['date'].strftime('%Y-%m-%d')
day_detail = None
for d in days:
if d['date'] == date_str:
day_detail = d
break
if day_detail:
print(f"\n {date_str}: 日收益={row['daily_return']:+.4%}, NAV={row['nav']:.4f}")
for code, asset in day_detail.get('assets', {}).items():
if asset.get('is_held'):
etf_ret = asset.get('etf_return_ctc', 0)
idx_ret = asset.get('index_return', 0)
premium = asset.get('premium', 0)
print(f" {code}: ETF收益={etf_ret:+.4%}, "
f"指数收益={idx_ret:+.4%}, 溢价率={premium:+.4%}" if premium else
f" {code}: ETF收益={etf_ret:+.4%}, 指数收益={idx_ret:+.4%}")
# 连续亏损天数分析
streak = 0
max_streak = 0
streaks = []
for r in returns:
if r < 0:
streak += 1
else:
if streak > 0:
streaks.append(streak)
streak = 0
if streak > 0:
streaks.append(streak)
max_streak = max(streaks) if streaks else 0
print(f"\n 连续亏损分析:")
print(f" 最大连续亏损天数: {max_streak}")
print(f" 连续亏损>=3天的次数: {sum(1 for s in streaks if s >= 3)}")
print(f" 连续亏损>=5天的次数: {sum(1 for s in streaks if s >= 5)}")
# 连续亏损段的累计收益
if streaks:
loss_streak_rets = []
current_streak_ret = 0
in_streak = False
for r in returns:
if r < 0:
current_streak_ret += r
in_streak = True
else:
if in_streak and current_streak_ret < -0.05:
loss_streak_rets.append(current_streak_ret)
current_streak_ret = 0
in_streak = False
if loss_streak_rets:
print(f"\n 显著连续亏损段(累计<-5%):")
for ret in sorted(loss_streak_rets):
print(f" 累计亏损: {ret:+.4%}")
return {'max_streak': max_streak, 'cvar_5pct': float(np.mean(tail_5pct))}
def main():
print_section("Task 6: 回撤诊断")
nav = load_nav()
signals = load_signals()
days = load_detail_days()
meta = load_detail_meta()
print(f" 数据期间: {meta['start_date']} ~ {meta['end_date']}")
results = {}
# 6.1 最大回撤复盘
results['max_dd'] = analyze_max_drawdown(days, nav)
# 6.2 近期回撤
results['recent_dd'] = analyze_recent_drawdown(days, nav)
# 6.3 尾部风险
results['tail'] = analyze_extreme_tail(days, nav)
print_section("Task 6 总结")
print(f" 1. 最大回撤 {results['max_dd']['max_dd']:.2%} 发生在 {results['max_dd']['trough_date']}")
print(f" 2. 2026 年最大回撤: {results['recent_dd'].get('max_dd_2026', 0):.2%}")
print(f" 3. CVaR(5%): {results['tail']['cvar_5pct']:+.4%}, "
f"最大连续亏损: {results['tail']['max_streak']}")
return results
if __name__ == '__main__':
main()