refactor(scheduler): 重构每日任务调度逻辑并优化配置路径

- 将等待目标时间逻辑改为基于schedule库的定时任务调度
- 支持后台守护进程模式持续执行定时任务
- 优化命令行参数说明,默认执行时间改为15:30
- 简化立即执行和循环运行的逻辑
- 修改SSH私钥路径为相对于项目根目录
- 更新rotation.yaml配置中指数及加密货币标签说明
- 回测开始日期由2022-01-01调整为2020-01-01

refactor(report): 优化轮动策略绩效报告图表与指标展示

- 新增策略与基准绩效指标对比表格,展示累计收益、年化收益等关键指标
- 调整绩效表布局,增加绩效指标面板高度,保持与信号表格一致视觉
- 丰富绘图函数参数,支持传入绩效指标字典避免重复计算
- 规范调仓信号表操作列索引及样式,保持统一字体大小和行高
- 净值曲线、回撤及持仓分布面板分离,调整图表索引和标题名称
- 优化持仓分布图显示,提升整体报告信息完整性与易读性
This commit is contained in:
2026-03-19 21:56:17 +08:00
parent 32831d7d6d
commit 8d24fb91eb
3 changed files with 182 additions and 73 deletions

View File

@@ -83,7 +83,15 @@ def generate_performance_report(
# 绘制图表
_plot_report_chart(
backtest_result, code_list, code_name_map,
benchmark_name, save_path, select_num
benchmark_name, save_path, select_num,
metrics={
"累计收益": s_total_return,
"年化收益": s_cagr_nat,
"夏普比率": s_sharpe,
"最大回撤": s_max_dd,
"Calmar比率": s_calmar,
"日胜率": s_win_rate,
}
)
# 返回指标字典
@@ -281,6 +289,7 @@ def _plot_report_chart(
benchmark_name: str,
save_path: str,
select_num: int,
metrics: dict = None,
):
"""绘制报告图表"""
plt.rcParams["font.sans-serif"] = ["Arial Unicode MS", "SimHei", "DejaVu Sans"]
@@ -288,16 +297,36 @@ def _plot_report_chart(
strategy_nav = backtest_result["轮动策略净值"]
benchmark_nav = backtest_result["基准净值"]
strategy_ret = backtest_result["轮动策略日收益率"]
# 计算绩效指标(如果没有传入)
if metrics is None:
from core.common.utils import calculate_cagr, calculate_max_drawdown, calculate_sharpe
s_cagr_nat = calculate_cagr(strategy_nav, "natural_days")
s_total_return = strategy_nav.iloc[-1] - 1
s_sharpe = calculate_sharpe(strategy_ret)
s_max_dd, s_dd_start, s_dd_end = calculate_max_drawdown(strategy_nav)
s_win_rate = (strategy_ret > 0).sum() / len(strategy_ret)
s_calmar = s_cagr_nat / abs(s_max_dd) if s_max_dd != 0 else np.inf
metrics = {
"累计收益": s_total_return,
"年化收益": s_cagr_nat,
"夏普比率": s_sharpe,
"最大回撤": s_max_dd,
"Calmar比率": s_calmar,
"日胜率": s_win_rate,
}
# 提取最新调仓信息
latest = _extract_latest_positions(backtest_result, code_list, code_name_map, select_num)
# 计算表格行数
n_table_rows = len(latest["positions"]) + len(latest["exit_positions"])
table_height = max(1.5, 0.5 + n_table_rows * 0.28)
signal_table_height = max(2.0, 0.6 + n_table_rows * 0.35)
metrics_table_height = 1.2
fig = plt.figure(figsize=(14, 10 + table_height + 8))
gs = fig.add_gridspec(4, 1, height_ratios=[table_height, 3, 1, 1.2], hspace=0.35)
fig = plt.figure(figsize=(14, 10 + signal_table_height + metrics_table_height + 8))
gs = fig.add_gridspec(5, 1, height_ratios=[signal_table_height, metrics_table_height, 3, 1, 1.2], hspace=0.35)
# 面板0: 最新调仓信号表
ax0 = fig.add_subplot(gs[0])
@@ -342,13 +371,14 @@ def _plot_report_chart(
table = ax0.table(
cellText=table_data,
colLabels=col_labels,
loc="upper center",
loc="center",
cellLoc="center",
colWidths=[0.08, 0.08, 0.05, 0.06, 0.06, 0.07, 0.07, 0.05, 0.06, 0.07],
colWidths=[0.10, 0.10, 0.07, 0.08, 0.08, 0.08, 0.08, 0.07, 0.08, 0.08],
bbox=[0, 0, 1, 1], # 使用完整宽度
)
table.auto_set_font_size(False)
table.set_fontsize(10)
table.scale(1, 2.0)
table.scale(1, 2.0) # 行高与绩效表格一致
# 表头深色
for j in range(len(col_labels)):
@@ -357,7 +387,7 @@ def _plot_report_chart(
# 数据行按操作着色
for i in range(len(table_data)):
action = table_data[i][3]
action = table_data[i][7] # 操作列在第8列
if action == "调入":
color = "#d4edda" # 绿色
elif action == "调出":
@@ -367,11 +397,92 @@ def _plot_report_chart(
for j in range(len(col_labels)):
table[i + 1, j].set_facecolor(color)
# 面板1: 净值曲线
# 面板1: 策略绩效指标对比表(转置:行为策略/基准,列为指标)
ax1 = fig.add_subplot(gs[1])
ax1.plot(strategy_nav.index, strategy_nav.values,
ax1.axis("off")
ax1.set_title("策略绩效对比", fontsize=14, fontweight="bold", loc="left", pad=10)
# 计算基准指标
from core.common.utils import calculate_cagr, calculate_max_drawdown, calculate_sharpe
benchmark_ret = backtest_result["基准日收益率"]
b_cagr_nat = calculate_cagr(benchmark_nav, "natural_days")
b_total_return = benchmark_nav.iloc[-1] - 1
b_sharpe = calculate_sharpe(benchmark_ret)
b_max_dd, _, _ = calculate_max_drawdown(benchmark_nav)
# 构建绩效对比表格(转置)
start_date = strategy_nav.index.min().strftime("%Y-%m-%d")
end_date = strategy_nav.index.max().strftime("%Y-%m-%d")
# 列标题(指标),第一列添加"策略"
perf_col_labels = ["策略", "开始时间", "结束时间", "累计收益", "年化收益", "最大回撤", "夏普比率", "Calmar比率", "日胜率"]
# 策略行数据(包含行标题)
strategy_row = [
"轮动策略",
start_date,
end_date,
f"{metrics.get('累计收益', 0):.2%}",
f"{metrics.get('年化收益', 0):.2%}",
f"{metrics.get('最大回撤', 0):.2%}",
f"{metrics.get('夏普比率', 0):.2f}",
f"{metrics.get('Calmar比率', 0):.2f}",
f"{metrics.get('日胜率', 0):.2%}",
]
# 基准行数据(包含行标题)
benchmark_row = [
f"基准({benchmark_name})",
start_date,
end_date,
f"{b_total_return:.2%}",
f"{b_cagr_nat:.2%}",
f"{b_max_dd:.2%}",
f"{b_sharpe:.2f}",
"",
"",
]
# 表格数据2行策略、基准
perf_table_data = [strategy_row, benchmark_row]
# 使用与调仓表格相同的列宽计算方式,确保总宽度一致
# 调仓表格有10列这里也有9列使用相似的宽度分配
perf_col_widths = [0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10]
perf_table = ax1.table(
cellText=perf_table_data,
colLabels=perf_col_labels,
loc="center",
cellLoc="center",
colWidths=perf_col_widths,
bbox=[0, 0, 1, 1], # 使用完整宽度,与调仓表格一致
)
perf_table.auto_set_font_size(False)
perf_table.set_fontsize(10) # 字体大小与调仓表格一致
perf_table.scale(1, 2.0) # 行高与调仓表格一致
# 表头样式(第一行)
for j in range(len(perf_col_labels)):
perf_table[0, j].set_facecolor("#2C3E50")
perf_table[0, j].set_text_props(color="white", fontweight="bold")
# 数据行样式
# 策略行浅绿背景
for j in range(len(perf_col_labels)):
perf_table[1, j].set_facecolor("#d4edda")
# 基准行浅蓝背景
for j in range(len(perf_col_labels)):
perf_table[2, j].set_facecolor("#cce5ff")
# 第一列(策略名称)加粗
for i in range(2):
perf_table[i + 1, 0].set_text_props(fontweight="bold")
# 面板2: 净值曲线
ax2 = fig.add_subplot(gs[2])
ax2.plot(strategy_nav.index, strategy_nav.values,
label="轮动策略", linewidth=2, color="#E74C3C")
ax1.plot(benchmark_nav.index, benchmark_nav.values,
ax2.plot(benchmark_nav.index, benchmark_nav.values,
label=benchmark_name, linewidth=1.5, color="#3498DB", alpha=0.8)
chart_colors = plt.cm.tab20.colors
@@ -379,27 +490,27 @@ def _plot_report_chart(
for i, code in enumerate(code_list):
name = code_name_map.get(code, code)
lbl = name if i < show_legend_n else None
ax1.plot(backtest_result.index, backtest_result[f"净值_{code}"].values,
ax2.plot(backtest_result.index, backtest_result[f"净值_{code}"].values,
label=lbl, linewidth=0.8, alpha=0.4,
color=chart_colors[i % len(chart_colors)])
ax1.set_title("ETF轮动策略 - 净值曲线", fontsize=16, fontweight="bold")
ax1.set_ylabel("净值")
ax1.legend(loc="upper left", fontsize=8, ncol=2)
ax1.grid(True, alpha=0.3)
ax1.set_yscale("log")
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")
# 面板2: 回撤曲线
ax2 = fig.add_subplot(gs[2])
# 面板3: 回撤曲线
ax3 = fig.add_subplot(gs[3])
cummax = strategy_nav.cummax()
drawdown = (strategy_nav - cummax) / cummax
ax2.fill_between(drawdown.index, drawdown.values, 0, alpha=0.5, color="#E74C3C")
ax2.set_title("策略回撤", fontsize=12)
ax2.set_ylabel("回撤")
ax2.grid(True, alpha=0.3)
ax3.fill_between(drawdown.index, drawdown.values, 0, alpha=0.5, color="#E74C3C")
ax3.set_title("策略回撤", fontsize=12)
ax3.set_ylabel("回撤")
ax3.grid(True, alpha=0.3)
# 面板3: 持仓分布
ax3 = fig.add_subplot(gs[3])
# 面板4: 持仓分布
ax4 = fig.add_subplot(gs[4])
signal_series = backtest_result["信号"]
for i, code in enumerate(code_list):
name = code_name_map.get(code, code)
@@ -408,16 +519,16 @@ def _plot_report_chart(
else:
mask = signal_series == code
if mask.any():
ax3.fill_between(signal_series.index, i, i + 0.8,
ax4.fill_between(signal_series.index, i, i + 0.8,
where=mask, alpha=0.7,
color=chart_colors[i % len(chart_colors)],
label=name)
ylabels = [code_name_map.get(c, c) for c in code_list]
ax3.set_title("每日持仓分布", fontsize=12)
ax3.set_yticks(range(len(ylabels)))
ax3.set_yticklabels(ylabels, fontsize=7)
ax3.grid(True, alpha=0.3)
ax4.set_title("每日持仓分布", fontsize=12)
ax4.set_yticks(range(len(ylabels)))
ax4.set_yticklabels(ylabels, fontsize=7)
ax4.grid(True, alpha=0.3)
chart_path = f"{save_path}_chart.png"
plt.savefig(chart_path, dpi=150, bbox_inches="tight")