""" ETF轮动策略 - 持仓跟踪模块 """ import pandas as pd from typing import Optional def track_positions( backtest_result: pd.DataFrame, code_name_map: dict = None, select_num: int = 1, ) -> tuple[pd.DataFrame, pd.DataFrame]: """ 从回测结果中提取每笔持仓记录 Args: backtest_result: 回测结果(含 '信号' 列) code_name_map: 代码→名称映射 select_num: 每次选中的品种数量 Returns: tuple: (trades_df, summary_df) """ code_name_map = code_name_map or {} data = backtest_result.copy() dates = data.index.tolist() signals = data["信号"].tolist() trades = [] if select_num == 1: # 单品种轮动 current_code = signals[0] entry_date = dates[0] entry_price = data.loc[entry_date, current_code] entry_nav = data.loc[entry_date, "轮动策略净值"] for i in range(1, len(dates)): today_code = signals[i] if today_code != current_code: exit_date = dates[i - 1] exit_price = data.loc[exit_date, current_code] exit_nav = data.loc[exit_date, "轮动策略净值"] holding_days = (i - 1) - dates.index(entry_date) + 1 trade_return = exit_price / entry_price - 1 if entry_price != 0 else 0 nav_contrib = exit_nav - entry_nav trades.append({ "序号": len(trades) + 1, "品种代码": current_code, "品种名称": code_name_map.get(current_code, current_code), "进场日期": entry_date, "出场日期": exit_date, "持仓天数": holding_days, "仓位占比": "100%", "进场价格": round(entry_price, 2), "出场价格": round(exit_price, 2), "持仓收益": trade_return, "进场净值": round(entry_nav, 4), "出场净值": round(exit_nav, 4), "净值贡献": round(nav_contrib, 4), }) current_code = today_code entry_date = dates[i] entry_price = data.loc[entry_date, current_code] entry_nav = data.loc[entry_date, "轮动策略净值"] # 最后一笔 exit_date = dates[-1] exit_price = data.loc[exit_date, current_code] exit_nav = data.loc[exit_date, "轮动策略净值"] holding_days = len(dates) - dates.index(entry_date) trade_return = exit_price / entry_price - 1 if entry_price != 0 else 0 nav_contrib = exit_nav - entry_nav trades.append({ "序号": len(trades) + 1, "品种代码": current_code, "品种名称": code_name_map.get(current_code, current_code), "进场日期": entry_date, "出场日期": exit_date, "持仓天数": holding_days, "仓位占比": "100%", "进场价格": round(entry_price, 2), "出场价格": round(exit_price, 2), "持仓收益": trade_return, "进场净值": round(entry_nav, 4), "出场净值": round(exit_nav, 4), "净值贡献": round(nav_contrib, 4), }) else: # 多品种等权轮动 current_signal = signals[0] entry_date = dates[0] codes = [c for c in current_signal.split(",") if c] # 过滤空字符串 if not codes: # 空信号,返回空结果 return pd.DataFrame(trades), pd.DataFrame() weight = 1.0 / len(codes) entry_prices = {c: data.loc[entry_date, c] for c in codes} entry_nav = data.loc[entry_date, "轮动策略净值"] for i in range(1, len(dates)): today_signal = signals[i] if today_signal != current_signal: exit_date = dates[i - 1] exit_nav = data.loc[exit_date, "轮动策略净值"] holding_days = (i - 1) - dates.index(entry_date) + 1 for c in codes: exit_price = data.loc[exit_date, c] ep = entry_prices[c] trade_return = exit_price / ep - 1 if ep != 0 else 0 trades.append({ "序号": len(trades) + 1, "品种代码": c, "品种名称": code_name_map.get(c, c), "进场日期": entry_date, "出场日期": exit_date, "持仓天数": holding_days, "仓位占比": f"{weight:.0%}", "进场价格": round(ep, 2), "出场价格": round(exit_price, 2), "持仓收益": trade_return, "进场净值": round(entry_nav, 4), "出场净值": round(exit_nav, 4), "净值贡献": round((exit_nav - entry_nav) * weight, 4), }) current_signal = today_signal entry_date = dates[i] codes = [c for c in current_signal.split(",") if c] # 过滤空字符串 if not codes: break # 空信号,结束循环 weight = 1.0 / len(codes) entry_prices = {c: data.loc[entry_date, c] for c in codes} entry_nav = data.loc[entry_date, "轮动策略净值"] # 最后一笔 exit_date = dates[-1] exit_nav = data.loc[exit_date, "轮动策略净值"] holding_days = len(dates) - dates.index(entry_date) for c in codes: exit_price = data.loc[exit_date, c] ep = entry_prices[c] trade_return = exit_price / ep - 1 if ep != 0 else 0 trades.append({ "序号": len(trades) + 1, "品种代码": c, "品种名称": code_name_map.get(c, c), "进场日期": entry_date, "出场日期": exit_date, "持仓天数": holding_days, "仓位占比": f"{weight:.0%}", "进场价格": round(ep, 2), "出场价格": round(exit_price, 2), "持仓收益": trade_return, "进场净值": round(entry_nav, 4), "出场净值": round(exit_nav, 4), "净值贡献": round((exit_nav - entry_nav) * weight, 4), }) trades_df = pd.DataFrame(trades) summary = _summarize_by_code(trades_df, code_name_map) return trades_df, summary def _summarize_by_code(trades_df: pd.DataFrame, code_name_map: dict) -> pd.DataFrame: """按品种汇总持仓统计""" if trades_df.empty: return pd.DataFrame() groups = trades_df.groupby("品种代码") rows = [] for code, grp in groups: total_trades = len(grp) total_days = grp["持仓天数"].sum() avg_days = grp["持仓天数"].mean() win_trades = (grp["持仓收益"] > 0).sum() win_rate = win_trades / total_trades if total_trades > 0 else 0 avg_return = grp["持仓收益"].mean() total_return = (1 + grp["持仓收益"]).prod() - 1 max_return = grp["持仓收益"].max() min_return = grp["持仓收益"].min() rows.append({ "品种代码": code, "品种名称": code_name_map.get(code, code), "调仓次数": total_trades, "总持仓天数": total_days, "平均持仓天数": round(avg_days, 1), "胜率": win_rate, "平均收益": avg_return, "累计收益": total_return, "最大单次收益": max_return, "最大单次亏损": min_return, }) summary = pd.DataFrame(rows) summary = summary.sort_values("总持仓天数", ascending=False).reset_index(drop=True) return summary def save_trades( trades_df: pd.DataFrame, summary_df: pd.DataFrame, save_path: str = "report", ) -> None: """保存调仓明细和汇总到CSV""" import os os.makedirs(os.path.dirname(save_path) if os.path.dirname(save_path) else ".", exist_ok=True) # 保存调仓明细 trades_path = f"{save_path}_trades.csv" if not trades_df.empty: trades_out = trades_df.copy() if "持仓收益" in trades_out.columns: trades_out["持仓收益"] = trades_out["持仓收益"].apply(lambda x: f"{x:.2%}") if "进场日期" in trades_out.columns: trades_out["进场日期"] = trades_out["进场日期"].apply( lambda x: x.strftime("%Y-%m-%d") if hasattr(x, "strftime") else str(x)[:10] ) if "出场日期" in trades_out.columns: trades_out["出场日期"] = trades_out["出场日期"].apply( lambda x: x.strftime("%Y-%m-%d") if hasattr(x, "strftime") else str(x)[:10] ) trades_out.to_csv(trades_path, index=False, encoding="utf-8-sig") print(f"\n调仓明细已保存: {trades_path}") else: # 创建空文件 pd.DataFrame().to_csv(trades_path, index=False, encoding="utf-8-sig") print(f"\n调仓明细为空: {trades_path}") # 保存品种汇总 summary_path = f"{save_path}_summary.csv" if not summary_df.empty: summary_out = summary_df.copy() for col in ["胜率", "平均收益", "累计收益", "最大单次收益", "最大单次亏损"]: if col in summary_out.columns: summary_out[col] = summary_out[col].apply(lambda x: f"{x:.2%}") summary_out.to_csv(summary_path, index=False, encoding="utf-8-sig") print(f"品种汇总已保存: {summary_path}") else: # 创建空文件 pd.DataFrame().to_csv(summary_path, index=False, encoding="utf-8-sig") print(f"品种汇总为空: {summary_path}")