feat(report): 新增月度收益矩阵热力图面板

在策略绩效对比表下方新增 Panel 2 月度收益矩阵:
- 行:年份(2020~2026),列:1月~12月 + 年度累计
- 单元格显示月度收益率百分比
- 红涨绿跌配色(A股习惯),sqrt非线性映射增强小收益可见性
- 年度列使用几何连乘计算全年累计收益
- 无数据单元格浅灰显示
This commit is contained in:
2026-06-08 00:43:54 +08:00
parent 13c69c2a0b
commit d5f35c0273

View File

@@ -1312,8 +1312,8 @@ class SimpleRotationStrategy:
n_rows = len(positions_info) + len(exit_positions) + len(unselected_positions)
signal_h = max(1.5, 0.5 + n_rows * 0.35)
fig = plt.figure(figsize=(16, 10 + signal_h + 1.2 + 8))
gs = fig.add_gridspec(5, 1, height_ratios=[signal_h, 1.2, 3, 1, 1.2], hspace=0.35)
fig = plt.figure(figsize=(16, 10 + signal_h + 1.2 + 1.5 + 8))
gs = fig.add_gridspec(6, 1, height_ratios=[signal_h, 1.2, 1.5, 3, 1, 1.2], hspace=0.35)
# Panel 0: Full asset ranking table
ax0 = fig.add_subplot(gs[0])
@@ -1394,51 +1394,123 @@ class SimpleRotationStrategy:
ptbl[1, j].set_facecolor("#d4edda")
ptbl[2, j].set_facecolor("#cce5ff")
# Panel 2: NAV curves
# Panel 2: Monthly returns heatmap (year × month matrix)
ax2 = fig.add_subplot(gs[2])
ax2.plot(strategy_nav.index, strategy_nav.values,
ax2.axis('off')
ax2.set_title('月度收益矩阵 (%)', fontsize=14, fontweight='bold', loc='left', pad=10)
# Resample to monthly NAV (last business day)
monthly_nav = strategy_nav.resample('ME').last().dropna()
monthly_ret = monthly_nav.pct_change().dropna()
# Build year-month matrix
monthly_ret_pct = (monthly_ret * 100).round(2)
year_month_df = pd.DataFrame({
'year': monthly_ret_pct.index.year,
'month': monthly_ret_pct.index.month,
'ret': monthly_ret_pct.values,
})
years = sorted(year_month_df['year'].unique())
month_labels = [f'{m}' for m in range(1, 13)]
col_labels = month_labels + ['年度']
# Build table data
table_data = []
for yr in years:
row = []
yr_total = 0.0
yr_count = 0
for m in range(1, 13):
mask = (year_month_df['year'] == yr) & (year_month_df['month'] == m)
vals = year_month_df.loc[mask, 'ret'].values
if len(vals) > 0:
v = float(vals[0])
row.append(v)
yr_total = (1 + yr_total / 100) * (1 + v / 100) * 100 - 100
yr_count += 1
else:
row.append(None)
row.append(round(yr_total, 2) if yr_count > 0 else None)
table_data.append(row)
if table_data:
cell_text = []
for row in table_data:
cell_text.append([f'{v:+.1f}' if v is not None else '' for v in row])
tbl = ax2.table(cellText=cell_text, rowLabels=[str(y) for y in years],
colLabels=col_labels, loc='center', cellLoc='center', bbox=[0, 0, 1, 1])
tbl.auto_set_font_size(False)
tbl.set_fontsize(9)
tbl.scale(1, 1.6)
# Style header (colLabels row)
for j in range(len(col_labels)):
tbl[0, j].set_facecolor('#2C3E50')
tbl[0, j].set_text_props(color='white', fontweight='bold')
# Color cells based on value (sqrt mapping for better visibility of small returns)
max_abs = max(abs(v) for row in table_data for v in row if v is not None) or 1
for i, row in enumerate(table_data):
for j, v in enumerate(row):
if v is not None:
intensity = min((abs(v) / max_abs) ** 0.5, 1.0)
if v >= 0:
r, g, b = 1.0, 1.0 - intensity * 0.55, 1.0 - intensity * 0.55
else:
r, g, b = 1.0 - intensity * 0.55, 1.0, 1.0 - intensity * 0.55
tbl[i + 1, j].set_facecolor((r, g, b))
else:
tbl[i + 1, j].set_facecolor('#f5f5f5')
# Annual column: stronger coloring
ann_v = row[-1]
if ann_v is not None:
intensity = min((abs(ann_v) / max_abs) ** 0.5, 1.0)
if ann_v >= 0:
r, g, b = 1.0, 1.0 - intensity * 0.7, 1.0 - intensity * 0.7
else:
r, g, b = 1.0 - intensity * 0.7, 1.0, 1.0 - intensity * 0.7
tbl[i + 1, len(col_labels) - 1].set_facecolor((r, g, b))
# Panel 3: NAV curves
ax3 = fig.add_subplot(gs[3])
ax3.plot(strategy_nav.index, strategy_nav.values,
label="轮动策略", linewidth=2, color="#E74C3C")
if benchmark_nav is not None:
ax2.plot(benchmark_nav.index, benchmark_nav.values,
ax3.plot(benchmark_nav.index, benchmark_nav.values,
label=self.benchmark_name, linewidth=1.5, color="#3498DB", alpha=0.8)
colors = plt.cm.tab20.colors
for i, code in enumerate(self.signal_codes):
if code in asset_navs:
cfg = code_config.get(code, {})
lbl = cfg.get('name', code) if i < 10 else None
ax2.plot(asset_navs[code].index, asset_navs[code].values,
ax3.plot(asset_navs[code].index, asset_navs[code].values,
label=lbl, linewidth=0.8, alpha=0.4,
color=colors[i % len(colors)])
ax2.set_title("ETF轮动策略 - 净值曲线", fontsize=16, fontweight="bold")
ax2.set_ylabel("净值")
ax2.legend(loc="upper left", fontsize=8, ncol=2)
ax2.grid(True, alpha=0.3)
ax2.set_yscale("log")
# Panel 3: Drawdown
ax3 = fig.add_subplot(gs[3])
drawdown = (strategy_nav - s_peak) / s_peak
ax3.fill_between(drawdown.index, drawdown.values, 0, alpha=0.5, color="#E74C3C")
ax3.set_title("策略回撤", fontsize=12)
ax3.set_ylabel("回撤")
ax3.set_title("ETF轮动策略 - 净值曲线", fontsize=16, fontweight="bold")
ax3.set_ylabel("净值")
ax3.legend(loc="upper left", fontsize=8, ncol=2)
ax3.grid(True, alpha=0.3)
ax3.set_yscale("log")
# Panel 4: Holdings distribution
# Panel 4: Drawdown
ax4 = fig.add_subplot(gs[4])
drawdown = (strategy_nav - s_peak) / s_peak
ax4.fill_between(drawdown.index, drawdown.values, 0, alpha=0.5, color="#E74C3C")
ax4.set_title("策略回撤", fontsize=12)
ax4.set_ylabel("回撤")
ax4.grid(True, alpha=0.3)
# Panel 5: Holdings distribution
ax5 = fig.add_subplot(gs[5])
holdings_series = df['holdings']
for i, code in enumerate(self.signal_codes):
cfg = code_config.get(code, {})
name = cfg.get('name', code)
mask = holdings_series.apply(lambda h: code in h)
if mask.any():
ax4.fill_between(mask.index, i, i + 0.8,
ax5.fill_between(mask.index, i, i + 0.8,
where=mask, alpha=0.7,
color=colors[i % len(colors)], label=name)
ylabels = [code_config.get(c, {}).get('name', c) for c in self.signal_codes]
ax4.set_title("每日持仓分布", fontsize=12)
ax4.set_yticks(range(len(ylabels)))
ax4.set_yticklabels(ylabels, fontsize=7)
ax4.grid(True, alpha=0.3)
ax5.set_title("每日持仓分布", fontsize=12)
ax5.set_yticks(range(len(ylabels)))
ax5.set_yticklabels(ylabels, fontsize=7)
ax5.grid(True, alpha=0.3)
chart_path = output_dir / 'simple_rotation_report.png'
plt.savefig(str(chart_path), dpi=150, bbox_inches="tight")