chore(config): 添加环境变量示例及.gitignore更新

- 新增 .env.example,包含 Tushare API、钉钉机器人和PostgreSQL数据库配置模板
- 更新.gitignore,忽略本地配置文件如 .env.local 和 config_local.py
- 添加对报表文件命名规则的支持,保留示例文件不忽略
- 删除废弃的 chart.py 及相关图表模块代码
- 新增 config/settings.py,实现从环境变量读取配置的统一接口
- 设置数据目录及缓存目录,确保目录存在,提高配置管理规范性
This commit is contained in:
2026-03-18 23:33:40 +08:00
parent 7c93be4b41
commit 988c2335fb
39 changed files with 2983 additions and 1011 deletions

View File

@@ -0,0 +1,175 @@
"""
ETF轮动策略 - 绩效报告模块
"""
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from typing import Optional
from core.common.utils import calculate_cagr, calculate_max_drawdown, calculate_sharpe
def generate_performance_report(
backtest_result: pd.DataFrame,
code_list: list,
code_name_map: dict = None,
benchmark_name: str = "沪深300指数",
save_path: str = "report",
select_num: int = 1,
) -> dict:
"""
生成完整的绩效报告
Args:
backtest_result: 回测结果
code_list: ETF代码列表
code_name_map: 代码到名称映射
benchmark_name: 基准名称
save_path: 报告保存路径前缀
select_num: 选中数量
Returns:
dict: 绩效指标字典
"""
import os
os.makedirs(os.path.dirname(save_path) if os.path.dirname(save_path) else ".", exist_ok=True)
code_name_map = code_name_map or {}
strategy_nav = backtest_result["轮动策略净值"]
strategy_ret = backtest_result["轮动策略日收益率"]
benchmark_nav = backtest_result["基准净值"]
benchmark_ret = backtest_result["基准日收益率"]
# 计算绩效指标
s_cagr_nat = calculate_cagr(strategy_nav, "natural_days")
s_cagr_trd = calculate_cagr(strategy_nav, "trading_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
b_cagr_nat = calculate_cagr(benchmark_nav, "natural_days")
b_cagr_trd = calculate_cagr(benchmark_nav, "trading_days")
b_total_return = benchmark_nav.iloc[-1] - 1
b_sharpe = calculate_sharpe(benchmark_ret)
b_max_dd, _, _ = calculate_max_drawdown(benchmark_nav)
# 打印绩效表格
print("\n" + "=" * 70)
print(" 绩效评估报告")
print("=" * 70)
print(f" 回测区间: {strategy_nav.index.min().date()} ~ {strategy_nav.index.max().date()}")
print(f" 交易天数: {len(strategy_nav)}")
print("-" * 70)
print(f' {"指标":<25} {"轮动策略":>15} {"基准(" + benchmark_name + ")":>18}')
print("-" * 70)
print(f' {"累计收益":<25} {s_total_return:>14.2%} {b_total_return:>17.2%}')
print(f' {"CAGR(自然日口径)":<25} {s_cagr_nat:>14.2%} {b_cagr_nat:>17.2%}')
print(f' {"CAGR(交易日口径)":<25} {s_cagr_trd:>14.2%} {b_cagr_trd:>17.2%}')
print(f' {"年化夏普比率":<25} {s_sharpe:>14.2f} {b_sharpe:>17.2f}')
print(f' {"最大回撤":<25} {s_max_dd:>14.2%} {b_max_dd:>17.2%}')
print(f' {"Calmar比率":<23} {s_calmar:>14.2f} {"--":>17}')
print(f' {"日胜率":<25} {s_win_rate:>14.2%} {"--":>17}')
print(f' {"最大回撤区间":<22} {str(s_dd_start.date()):>10} ~ {str(s_dd_end.date())}')
print("=" * 70)
# 绘制图表
_plot_report_chart(
backtest_result, code_list, code_name_map,
benchmark_name, save_path, select_num
)
# 返回指标字典
return {
"累计收益": s_total_return,
"CAGR_自然日": s_cagr_nat,
"CAGR_交易日": s_cagr_trd,
"夏普比率": s_sharpe,
"最大回撤": s_max_dd,
"Calmar比率": s_calmar,
"日胜率": s_win_rate,
"基准累计收益": b_total_return,
"基准CAGR_自然日": b_cagr_nat,
"基准夏普比率": b_sharpe,
"基准最大回撤": b_max_dd,
}
def _plot_report_chart(
backtest_result: pd.DataFrame,
code_list: list,
code_name_map: dict,
benchmark_name: str,
save_path: str,
select_num: int,
):
"""绘制报告图表"""
plt.rcParams["font.sans-serif"] = ["Arial Unicode MS", "SimHei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
strategy_nav = backtest_result["轮动策略净值"]
benchmark_nav = backtest_result["基准净值"]
fig, axes = plt.subplots(3, 1, figsize=(14, 12))
# 面板1: 净值曲线
ax1 = axes[0]
ax1.plot(strategy_nav.index, strategy_nav.values,
label="轮动策略", linewidth=2, color="#E74C3C")
ax1.plot(benchmark_nav.index, benchmark_nav.values,
label=benchmark_name, linewidth=1.5, color="#3498DB", alpha=0.8)
chart_colors = plt.cm.tab20.colors
show_legend_n = min(len(code_list), 10)
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,
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")
# 面板2: 回撤曲线
ax2 = axes[1]
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)
# 面板3: 持仓分布
ax3 = axes[2]
signal_series = backtest_result["信号"]
for i, code in enumerate(code_list):
name = code_name_map.get(code, code)
if select_num > 1:
mask = signal_series.str.contains(code, regex=False, na=False)
else:
mask = signal_series == code
if mask.any():
ax3.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)
plt.tight_layout()
chart_path = f"{save_path}_chart.png"
plt.savefig(chart_path, dpi=150, bbox_inches="tight")
plt.close()
print(f"\n报告图表已保存: {chart_path}")