From e6ddea518c2775c23aa79298d3b796bbf03e8751 Mon Sep 17 00:00:00 2001 From: aszerW Date: Wed, 25 Mar 2026 22:02:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(report):=20=E6=94=AF=E6=8C=81ETF=E5=87=80?= =?UTF-8?q?=E5=80=BC=E5=92=8C=E6=BA=A2=E4=BB=B7=E7=8E=87=E7=9A=84=E7=BB=A9?= =?UTF-8?q?=E6=95=88=E6=8A=A5=E5=91=8A=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在生成绩效报告接口中新增code_config、index_data、etf_price_data和etf_nav_data_raw参数 - 计算溢价率并基于信号前一日数据进行校验和计算 - 打印最新调仓信号时增加ETF代码、ETF净值、溢价率及高溢价警告显示 - 调整信号数据基准日期展示,更准确反映信号计算依据 - 报告图表支持显示ETF净值和溢价率列,完善调仓信息视觉效果 - 统一处理跨市场ETF映射和特殊市场(如加密货币)情况,避免溢价率误报 - 完善打印表格和图表的列宽和格式,增强可读性 --- strategies/rotation/report.py | 208 ++++++++++++++++++++++++++++++---- 1 file changed, 186 insertions(+), 22 deletions(-) diff --git a/strategies/rotation/report.py b/strategies/rotation/report.py index d151dec..0ceaa1e 100644 --- a/strategies/rotation/report.py +++ b/strategies/rotation/report.py @@ -19,6 +19,10 @@ def generate_performance_report( benchmark_name: str = "沪深300指数", save_path: str = "report", select_num: int = 1, + code_config: dict = None, + index_data: pd.DataFrame = None, + etf_price_data: pd.DataFrame = None, + etf_nav_data_raw: pd.DataFrame = None, ) -> dict: """ 生成完整的绩效报告 @@ -30,6 +34,10 @@ def generate_performance_report( benchmark_name: 基准名称 save_path: 报告保存路径前缀 select_num: 选中数量 + code_config: 代码配置(包含 name, etf, market),用于显示ETF映射 + index_data: 指数价格数据 + etf_price_data: ETF价格数据(用于计算溢价率) + etf_nav_data_raw: ETF净值数据(用于计算溢价率) Returns: dict: 绩效指标字典 @@ -38,6 +46,7 @@ def generate_performance_report( os.makedirs(os.path.dirname(save_path) if os.path.dirname(save_path) else ".", exist_ok=True) code_name_map = code_name_map or {} + code_config = code_config or {} strategy_nav = backtest_result["轮动策略净值"] strategy_ret = backtest_result["轮动策略日收益率"] benchmark_nav = backtest_result["基准净值"] @@ -77,8 +86,30 @@ def generate_performance_report( print(f' {"最大回撤区间":<22} {str(s_dd_start.date()):>10} ~ {str(s_dd_end.date())}') print("=" * 70) + # 计算溢价率(需要ETF价格和ETF净值) + # 溢价率 = (ETF价格 - ETF净值) / ETF净值 + # 注意:信号是基于前一日数据计算的,所以使用 signal_date - 1 的数据 + etf_nav_data = {} + premium_data = {} + + if etf_price_data is not None and etf_nav_data_raw is not None: + # 使用信号前一天的数据(因为信号是基于前一天收盘数据计算的) + signal_date = backtest_result.index[-1] + data_base_date = signal_date - pd.Timedelta(days=1) + + # 确保数据基准日期在数据范围内 + if data_base_date in etf_price_data.index and data_base_date in etf_nav_data_raw.index: + for code in code_list: + if code in etf_price_data.columns and code in etf_nav_data_raw.columns: + etf_price = etf_price_data.loc[data_base_date, code] + etf_nav = etf_nav_data_raw.loc[data_base_date, code] + if pd.notna(etf_price) and pd.notna(etf_nav) and etf_nav > 0: + premium = (etf_price - etf_nav) / etf_nav + premium_data[code] = premium + etf_nav_data[code] = etf_nav + # 打印最新调仓信号 - _print_latest_signal(backtest_result, code_list, code_name_map, select_num) + _print_latest_signal(backtest_result, code_list, code_name_map, select_num, code_config, etf_nav_data, premium_data) # 绘制图表 _plot_report_chart( @@ -91,7 +122,10 @@ def generate_performance_report( "最大回撤": s_max_dd, "Calmar比率": s_calmar, "日胜率": s_win_rate, - } + }, + code_config=code_config, + etf_price_data=etf_price_data, + etf_nav_data_raw=etf_nav_data_raw, ) # 返回指标字典 @@ -110,21 +144,30 @@ def generate_performance_report( } -def _print_latest_signal(backtest_result: pd.DataFrame, code_list: list, code_name_map: dict, select_num: int): - """打印最新调仓信号""" +def _print_latest_signal(backtest_result: pd.DataFrame, code_list: list, code_name_map: dict, select_num: int, code_config: dict = None, etf_nav_data: dict = None, premium_data: dict = None): + """打印最新调仓信号(支持ETF映射、ETF净值和溢价率显示)""" + code_config = code_config or {} + etf_nav_data = etf_nav_data or {} + premium_data = premium_data or {} latest = _extract_latest_positions(backtest_result, code_list, code_name_map, select_num) - signal_date_str = latest["signal_date"].strftime("%Y-%m-%d") + signal_date = latest["signal_date"] + signal_date_str = signal_date.strftime("%Y-%m-%d") + + # 数据基准日期(信号是基于前一日数据计算的) + # 根据跨市场ETF映射方案:T+1日09:00计算的信号基于T日数据 + data_base_date = signal_date - pd.Timedelta(days=1) + data_base_date_str = data_base_date.strftime("%Y-%m-%d") print("\n") - print("=" * 100) + print("=" * 135) print(" 最新调仓信号 (下一交易日执行)") - print("=" * 100) - print(f" 数据截止: {signal_date_str}") + print("=" * 135) + print(f" 信号日期: {signal_date_str} (基于 {data_base_date_str} 收盘数据)") print() - # 表头 - print(f' {"标的名称":<8} {"代码":>10} {"仓位":>6} {"得分":>8} {"进场日期":>12} {"进场价":>10} {"最新价":>10} {"操作":>6} {"持有天数":>8} {"盈亏":>10}') - print(" " + "-" * 115) + # 表头 - 添加ETF净值和溢价率列 + print(f' {"标的名称":<10} {"指数代码":>12} {"ETF代码":>12} {"仓位":>6} {"得分":>8} {"进场日期":>12} {"指数进场价":>10} {"指数最新价":>10} {"ETF净值":>10} {"溢价率":>8} {"操作":>6} {"持有天数":>8} {"盈亏":>10}') + print(" " + "-" * 155) # 下期持仓(调入/维持) for pos in latest["positions"]: @@ -134,8 +177,37 @@ def _print_latest_signal(backtest_result: pd.DataFrame, code_list: list, code_na entry_date_str = pos["entry_date"].strftime("%Y-%m-%d") if pos.get("entry_date") else ' —' score_str = f'{pos["score"]:>8.2f}' if pos["score"] is not None else ' —' flag = '▲' if pos["action"] == "调入" else ' ' + + # 获取ETF代码、ETF净值和溢价率 + idx_code = pos["code"] + cfg = code_config.get(idx_code, {}) + etf_code = cfg.get('etf', '—') + market = cfg.get('market', 'A') + if etf_code is None: + etf_code = '直接交易' + + # 获取ETF净值和溢价率 + if market == 'CRYPTO': + etf_nav_str = ' —' + premium_str = ' —' + else: + # ETF净值 + etf_nav = etf_nav_data.get(idx_code) + if etf_nav is not None: + etf_nav_str = f'{etf_nav:>10.3f}' + else: + etf_nav_str = ' —' + + # 溢价率 + premium = premium_data.get(idx_code) + if premium is not None: + # 高溢价警告标记 + warning = '⚠️' if premium > 0.02 else '' + premium_str = f'{premium:>+7.2%}{warning}' + else: + premium_str = ' —' - print(f' {pos["name"]:<8} {pos["code"]:>10} {pos["weight"]:>6.0%} {score_str} {entry_date_str:>12} {entry_str} {pos["current_price"]:>10.2f} {flag}{pos["action"]:>4} {days_str} {pnl_str}') + print(f' {pos["name"]:<10} {idx_code:>12} {etf_code:>12} {pos["weight"]:>6.0%} {score_str} {entry_date_str:>12} {entry_str} {pos["current_price"]:>10.2f} {etf_nav_str} {premium_str} {flag}{pos["action"]:>4} {days_str} {pnl_str}') # 需调出的品种 if latest["exit_positions"]: @@ -147,10 +219,38 @@ def _print_latest_signal(backtest_result: pd.DataFrame, code_list: list, code_na entry_str = f'{pos["entry_price"]:>10.2f}' if pos["entry_price"] is not None else ' —' entry_date_str = pos["entry_date"].strftime("%Y-%m-%d") if pos.get("entry_date") else ' —' score_str = ' —' # 调出品种无得分 + + # 获取ETF代码、ETF净值和溢价率 + idx_code = pos["code"] + cfg = code_config.get(idx_code, {}) + etf_code = cfg.get('etf', '—') + market = cfg.get('market', 'A') + if etf_code is None: + etf_code = '直接交易' + + # 获取ETF净值和溢价率 + if market == 'CRYPTO': + etf_nav_str = ' —' + premium_str = ' —' + else: + # ETF净值 + etf_nav = etf_nav_data.get(idx_code) + if etf_nav is not None: + etf_nav_str = f'{etf_nav:>10.3f}' + else: + etf_nav_str = ' —' + + # 溢价率 + premium = premium_data.get(idx_code) + if premium is not None: + warning = '⚠️' if premium > 0.02 else '' + premium_str = f'{premium:>+7.2%}{warning}' + else: + premium_str = ' —' - print(f' {pos["name"]:<8} {pos["code"]:>10} {pos["weight"]:>6.0%} {score_str} {entry_date_str:>12} {entry_str} {pos["current_price"]:>10.2f} ▼调出 {days_str} {pnl_str}') + print(f' {pos["name"]:<10} {idx_code:>12} {etf_code:>12} {pos["weight"]:>6.0%} {score_str} {entry_date_str:>12} {entry_str} {pos["current_price"]:>10.2f} {etf_nav_str} {premium_str} ▼调出 {days_str} {pnl_str}') - print("=" * 120) + print("=" * 160) def _extract_latest_positions(backtest_result: pd.DataFrame, code_list: list, code_name_map: dict, select_num: int) -> dict: @@ -290,8 +390,11 @@ def _plot_report_chart( save_path: str, select_num: int, metrics: dict = None, + code_config: dict = None, + etf_price_data: pd.DataFrame = None, + etf_nav_data_raw: pd.DataFrame = None, ): - """绘制报告图表""" + """绘制报告图表(支持ETF净值和溢价率显示)""" # 设置中文字体(macOS: Arial Unicode MS, Linux: WenQuanYi Zen Hei) plt.rcParams["font.sans-serif"] = ["Arial Unicode MS", "WenQuanYi Zen Hei", "DejaVu Sans"] plt.rcParams["axes.unicode_minus"] = False @@ -321,6 +424,27 @@ def _plot_report_chart( # 提取最新调仓信息 latest = _extract_latest_positions(backtest_result, code_list, code_name_map, select_num) + + # 准备配置数据 + code_config = code_config or {} + signal_date = backtest_result.index[-1] + # 数据基准日期(信号是基于前一日数据计算的) + data_base_date = signal_date - pd.Timedelta(days=1) + + # 计算ETF净值和溢价率(使用数据基准日期的数据) + etf_nav_dict = {} + premium_dict = {} + if etf_price_data is not None and etf_nav_data_raw is not None: + # 确保数据基准日期在数据范围内 + if data_base_date in etf_price_data.index and data_base_date in etf_nav_data_raw.index: + for code in code_list: + if code in etf_price_data.columns and code in etf_nav_data_raw.columns: + etf_price = etf_price_data.loc[data_base_date, code] + etf_nav = etf_nav_data_raw.loc[data_base_date, code] + if pd.notna(etf_price) and pd.notna(etf_nav) and etf_nav > 0: + premium = (etf_price - etf_nav) / etf_nav + etf_nav_dict[code] = etf_nav + premium_dict[code] = premium # 计算表格行数 n_table_rows = len(latest["positions"]) + len(latest["exit_positions"]) @@ -334,12 +458,16 @@ def _plot_report_chart( ax0 = fig.add_subplot(gs[0]) ax0.axis("off") - signal_date_str = latest["signal_date"].strftime("%Y-%m-%d") - ax0.set_title(f"最新调仓信号 (数据截止: {signal_date_str},下一交易日执行)", fontsize=14, fontweight="bold", loc="left", pad=15) + signal_date = latest["signal_date"] + signal_date_str = signal_date.strftime("%Y-%m-%d") + # 数据基准日期(信号是基于前一日数据计算的) + data_base_date = signal_date - pd.Timedelta(days=1) + data_base_date_str = data_base_date.strftime("%Y-%m-%d") + ax0.set_title(f"最新调仓信号 (信号日期: {signal_date_str},基于 {data_base_date_str} 数据,下一交易日执行)", fontsize=14, fontweight="bold", loc="left", pad=15) - # 构建表格数据 + # 构建表格数据(添加ETF净值和溢价率列) table_data = [] - col_labels = ["标的名称", "代码", "仓位", "得分", "进场日期", "进场价", "最新价", "操作", "持有天数", "盈亏"] + col_labels = ["标的名称", "指数代码", "仓位", "得分", "进场日期", "进场价", "最新价", "ETF净值", "溢价率", "操作", "持有天数", "盈亏"] # 下期持仓(调入/维持) for pos in latest["positions"]: @@ -348,11 +476,29 @@ def _plot_report_chart( entry_str = f'{pos["entry_price"]:.2f}' if pos["entry_price"] is not None else "—" entry_date_str = pos["entry_date"].strftime("%m-%d") if pos.get("entry_date") else "—" score_str = f'{pos["score"]:.2f}' if pos["score"] is not None else "—" + + # 获取ETF净值和溢价率 + idx_code = pos["code"] + cfg = code_config.get(idx_code, {}) + market = cfg.get('market', 'A') + + if market == 'CRYPTO': + etf_nav_str = "—" + premium_str = "—" + else: + etf_nav = etf_nav_dict.get(idx_code) + premium = premium_dict.get(idx_code) + etf_nav_str = f"{etf_nav:.3f}" if etf_nav is not None else "—" + if premium is not None: + warning = "⚠️" if premium > 0.02 else "" + premium_str = f"{premium:+.2%}{warning}" + else: + premium_str = "—" table_data.append([ pos["name"], pos["code"], f'{pos["weight"]:.0%}', score_str, entry_date_str, entry_str, f'{pos["current_price"]:.2f}', - pos["action"], days_str, pnl_str + etf_nav_str, premium_str, pos["action"], days_str, pnl_str ]) # 需调出的品种 @@ -362,11 +508,29 @@ def _plot_report_chart( entry_str = f'{pos["entry_price"]:.2f}' if pos["entry_price"] is not None else "—" entry_date_str = pos["entry_date"].strftime("%m-%d") if pos.get("entry_date") else "—" score_str = "—" # 调出品种无得分 + + # 获取ETF净值和溢价率 + idx_code = pos["code"] + cfg = code_config.get(idx_code, {}) + market = cfg.get('market', 'A') + + if market == 'CRYPTO': + etf_nav_str = "—" + premium_str = "—" + else: + etf_nav = etf_nav_dict.get(idx_code) + premium = premium_dict.get(idx_code) + etf_nav_str = f"{etf_nav:.3f}" if etf_nav is not None else "—" + if premium is not None: + warning = "⚠️" if premium > 0.02 else "" + premium_str = f"{premium:+.2%}{warning}" + else: + premium_str = "—" table_data.append([ pos["name"], pos["code"], f'{pos["weight"]:.0%}', score_str, entry_date_str, entry_str, f'{pos["current_price"]:.2f}', - "调出", days_str, pnl_str + etf_nav_str, premium_str, "调出", days_str, pnl_str ]) if table_data: @@ -375,7 +539,7 @@ def _plot_report_chart( colLabels=col_labels, loc="center", cellLoc="center", - colWidths=[0.10, 0.10, 0.07, 0.08, 0.08, 0.08, 0.08, 0.07, 0.08, 0.08], + colWidths=[0.09, 0.09, 0.06, 0.07, 0.07, 0.07, 0.07, 0.07, 0.08, 0.06, 0.07, 0.07], bbox=[0, 0, 1, 1], # 使用完整宽度 ) table.auto_set_font_size(False)