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

@@ -32,9 +32,9 @@ code_list:
"399702.SZ": "国债指数" "399702.SZ": "国债指数"
# 全球市场指数 (使用 YFinance) - 非主市场数据会前向填充到A股交易日 # 全球市场指数 (使用 YFinance) - 非主市场数据会前向填充到A股交易日
"HSTECH": "恒生科技" # 港股 "HSTECH": "恒生科技" # 港股
"NDX": "纳斯达克100" # 美股 "NDX": "纳100" # 美股
"BTC": "比特币" # 加密货币 "BTC": "BTC" # 加密货币
"ETH": "以太坊" # 加密货币 "ETH": "ETH" # 加密货币
# 主市场配置(用于确定交易日历) # 主市场配置(用于确定交易日历)
primary_market: primary_market:
@@ -47,7 +47,7 @@ benchmark:
name: "沪深300指数" name: "沪深300指数"
# ==================== 回测参数 ==================== # ==================== 回测参数 ====================
start_date: "2022-01-01" start_date: "2020-01-01"
# end_date: "2025-03-17" # end_date: "2025-03-17"
# ==================== 因子参数 ==================== # ==================== 因子参数 ====================
@@ -79,5 +79,5 @@ ssh_tunnel:
host: "8.218.167.69" # SSH 服务器地址(阿里云香港 ECS IP host: "8.218.167.69" # SSH 服务器地址(阿里云香港 ECS IP
port: 22 # SSH 端口 port: 22 # SSH 端口
username: "root" # SSH 用户名 username: "root" # SSH 用户名
key_path: "/Users/aszer/Documents/vscode/etf/hk_ecs.pem" # SSH 私钥路径 key_path: "hk_ecs.pem" # SSH 私钥路径(相对于项目根目录)
local_port: 1080 # 本地 SOCKS5 代理端口 local_port: 1080 # 本地 SOCKS5 代理端口

View File

@@ -18,7 +18,7 @@ import os
import time import time
import argparse import argparse
import subprocess import subprocess
from datetime import datetime, timedelta from datetime import datetime
from pathlib import Path from pathlib import Path
# 添加项目根目录到路径 # 添加项目根目录到路径
@@ -29,6 +29,7 @@ from dotenv import load_dotenv
load_dotenv(project_root / ".env") load_dotenv(project_root / ".env")
from loguru import logger from loguru import logger
import schedule
import tushare as ts import tushare as ts
from core.common.notify import DingTalkBot from core.common.notify import DingTalkBot
from core.common.oss_utils import upload_image_to_oss from core.common.oss_utils import upload_image_to_oss
@@ -182,36 +183,23 @@ def send_report_to_dingtalk(chart_path: str, summary_text: str = "") -> bool:
return False return False
def wait_until_target_time(target_time: str = "15:00"): def setup_schedule(target_time: str = "15:30", config_path: str = "config/strategies/rotation.yaml"):
""" """
等待直到目标时间 设置定时任务
Args: Args:
target_time: 目标时间 (HH:MM) target_time: 执行时间 (HH:MM)
config_path: 配置文件路径
""" """
while True: logger.info(f"设置定时任务: 每天 {target_time} 执行")
now = datetime.now()
target = now.replace(
hour=int(target_time.split(":")[0]),
minute=int(target_time.split(":")[1]),
second=0,
microsecond=0
)
# 如果目标时间已过,等到明天 # 清除所有现有任务
if target < now: schedule.clear()
target += timedelta(days=1)
logger.info(f"目标时间已过,等到明天 {target_time}")
wait_seconds = (target - now).total_seconds() # 添加每日任务
schedule.every().day.at(target_time).do(daily_task, config_path)
if wait_seconds > 60: logger.info("定时任务设置完成,等待执行...")
logger.info(f"等待 {target_time},还需 {wait_seconds/60:.0f} 分钟...")
time.sleep(60) # 每分钟检查一次
else:
logger.info(f"即将到达目标时间,等待 {wait_seconds:.0f} 秒...")
time.sleep(wait_seconds)
break
def daily_task(config_path: str = "config/strategies/rotation.yaml"): def daily_task(config_path: str = "config/strategies/rotation.yaml"):
@@ -252,13 +240,24 @@ def daily_task(config_path: str = "config/strategies/rotation.yaml"):
logger.info("每日任务完成") logger.info("每日任务完成")
def run_scheduler_loop():
"""运行调度器循环"""
logger.info("启动调度器循环,按 Ctrl+C 停止")
try:
while True:
schedule.run_pending()
time.sleep(1)
except KeyboardInterrupt:
logger.info("调度器已停止")
def main(): def main():
parser = argparse.ArgumentParser(description="ETF策略每日定时任务") parser = argparse.ArgumentParser(description="ETF策略每日定时任务")
parser.add_argument( parser.add_argument(
"--time", "--time",
type=str, type=str,
default="15:30", default="15:30",
help="执行时间 (HH:MM)默认15:00", help="执行时间 (HH:MM)默认15:30",
) )
parser.add_argument( parser.add_argument(
"--config", "--config",
@@ -269,12 +268,12 @@ def main():
parser.add_argument( parser.add_argument(
"--run-now", "--run-now",
action="store_true", action="store_true",
help="立即执行一次(不等待指定时间", help="立即执行一次(不启动定时任务",
) )
parser.add_argument( parser.add_argument(
"--loop", "--daemon",
action="store_true", action="store_true",
help="循环运行(每天执行", help="后台运行(持续执行定时任务",
) )
args = parser.parse_args() args = parser.parse_args()
@@ -284,18 +283,17 @@ def main():
if args.run_now: if args.run_now:
# 立即执行一次 # 立即执行一次
daily_task(args.config) daily_task(args.config)
elif args.loop: elif args.daemon:
# 循环运行 # 后台运行模式
logger.info(f"启动定时任务,每天 {args.time} 执行") setup_schedule(args.time, args.config)
while True: run_scheduler_loop()
wait_until_target_time(args.time)
daily_task(args.config)
# 等待一段时间避免重复执行
time.sleep(60)
else: else:
# 等待到目标时间执行一次 # 默认:设置定时任务并执行一次(用于测试)
wait_until_target_time(args.time) setup_schedule(args.time, args.config)
logger.info("执行一次任务用于测试...")
daily_task(args.config) daily_task(args.config)
logger.info("测试完成,启动定时任务循环(按 Ctrl+C 停止)...")
run_scheduler_loop()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -83,7 +83,15 @@ def generate_performance_report(
# 绘制图表 # 绘制图表
_plot_report_chart( _plot_report_chart(
backtest_result, code_list, code_name_map, 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, benchmark_name: str,
save_path: str, save_path: str,
select_num: int, select_num: int,
metrics: dict = None,
): ):
"""绘制报告图表""" """绘制报告图表"""
plt.rcParams["font.sans-serif"] = ["Arial Unicode MS", "SimHei", "DejaVu Sans"] plt.rcParams["font.sans-serif"] = ["Arial Unicode MS", "SimHei", "DejaVu Sans"]
@@ -288,16 +297,36 @@ def _plot_report_chart(
strategy_nav = backtest_result["轮动策略净值"] strategy_nav = backtest_result["轮动策略净值"]
benchmark_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) latest = _extract_latest_positions(backtest_result, code_list, code_name_map, select_num)
# 计算表格行数 # 计算表格行数
n_table_rows = len(latest["positions"]) + len(latest["exit_positions"]) 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)) fig = plt.figure(figsize=(14, 10 + signal_table_height + metrics_table_height + 8))
gs = fig.add_gridspec(4, 1, height_ratios=[table_height, 3, 1, 1.2], hspace=0.35) gs = fig.add_gridspec(5, 1, height_ratios=[signal_table_height, metrics_table_height, 3, 1, 1.2], hspace=0.35)
# 面板0: 最新调仓信号表 # 面板0: 最新调仓信号表
ax0 = fig.add_subplot(gs[0]) ax0 = fig.add_subplot(gs[0])
@@ -342,13 +371,14 @@ def _plot_report_chart(
table = ax0.table( table = ax0.table(
cellText=table_data, cellText=table_data,
colLabels=col_labels, colLabels=col_labels,
loc="upper center", loc="center",
cellLoc="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.auto_set_font_size(False)
table.set_fontsize(10) table.set_fontsize(10)
table.scale(1, 2.0) table.scale(1, 2.0) # 行高与绩效表格一致
# 表头深色 # 表头深色
for j in range(len(col_labels)): for j in range(len(col_labels)):
@@ -357,7 +387,7 @@ def _plot_report_chart(
# 数据行按操作着色 # 数据行按操作着色
for i in range(len(table_data)): for i in range(len(table_data)):
action = table_data[i][3] action = table_data[i][7] # 操作列在第8列
if action == "调入": if action == "调入":
color = "#d4edda" # 绿色 color = "#d4edda" # 绿色
elif action == "调出": elif action == "调出":
@@ -367,11 +397,92 @@ def _plot_report_chart(
for j in range(len(col_labels)): for j in range(len(col_labels)):
table[i + 1, j].set_facecolor(color) table[i + 1, j].set_facecolor(color)
# 面板1: 净值曲线 # 面板1: 策略绩效指标对比表(转置:行为策略/基准,列为指标)
ax1 = fig.add_subplot(gs[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") 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) label=benchmark_name, linewidth=1.5, color="#3498DB", alpha=0.8)
chart_colors = plt.cm.tab20.colors chart_colors = plt.cm.tab20.colors
@@ -379,27 +490,27 @@ def _plot_report_chart(
for i, code in enumerate(code_list): for i, code in enumerate(code_list):
name = code_name_map.get(code, code) name = code_name_map.get(code, code)
lbl = name if i < show_legend_n else None 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, label=lbl, linewidth=0.8, alpha=0.4,
color=chart_colors[i % len(chart_colors)]) color=chart_colors[i % len(chart_colors)])
ax1.set_title("ETF轮动策略 - 净值曲线", fontsize=16, fontweight="bold") ax2.set_title("ETF轮动策略 - 净值曲线", fontsize=16, fontweight="bold")
ax1.set_ylabel("净值") ax2.set_ylabel("净值")
ax1.legend(loc="upper left", fontsize=8, ncol=2) ax2.legend(loc="upper left", fontsize=8, ncol=2)
ax1.grid(True, alpha=0.3) ax2.grid(True, alpha=0.3)
ax1.set_yscale("log") ax2.set_yscale("log")
# 面板2: 回撤曲线 # 面板3: 回撤曲线
ax2 = fig.add_subplot(gs[2]) ax3 = fig.add_subplot(gs[3])
cummax = strategy_nav.cummax() cummax = strategy_nav.cummax()
drawdown = (strategy_nav - cummax) / cummax drawdown = (strategy_nav - cummax) / cummax
ax2.fill_between(drawdown.index, drawdown.values, 0, alpha=0.5, color="#E74C3C") ax3.fill_between(drawdown.index, drawdown.values, 0, alpha=0.5, color="#E74C3C")
ax2.set_title("策略回撤", fontsize=12) ax3.set_title("策略回撤", fontsize=12)
ax2.set_ylabel("回撤") ax3.set_ylabel("回撤")
ax2.grid(True, alpha=0.3) ax3.grid(True, alpha=0.3)
# 面板3: 持仓分布 # 面板4: 持仓分布
ax3 = fig.add_subplot(gs[3]) ax4 = fig.add_subplot(gs[4])
signal_series = backtest_result["信号"] signal_series = backtest_result["信号"]
for i, code in enumerate(code_list): for i, code in enumerate(code_list):
name = code_name_map.get(code, code) name = code_name_map.get(code, code)
@@ -408,16 +519,16 @@ def _plot_report_chart(
else: else:
mask = signal_series == code mask = signal_series == code
if mask.any(): 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, where=mask, alpha=0.7,
color=chart_colors[i % len(chart_colors)], color=chart_colors[i % len(chart_colors)],
label=name) label=name)
ylabels = [code_name_map.get(c, c) for c in code_list] ylabels = [code_name_map.get(c, c) for c in code_list]
ax3.set_title("每日持仓分布", fontsize=12) ax4.set_title("每日持仓分布", fontsize=12)
ax3.set_yticks(range(len(ylabels))) ax4.set_yticks(range(len(ylabels)))
ax3.set_yticklabels(ylabels, fontsize=7) ax4.set_yticklabels(ylabels, fontsize=7)
ax3.grid(True, alpha=0.3) ax4.grid(True, alpha=0.3)
chart_path = f"{save_path}_chart.png" chart_path = f"{save_path}_chart.png"
plt.savefig(chart_path, dpi=150, bbox_inches="tight") plt.savefig(chart_path, dpi=150, bbox_inches="tight")