diff --git a/framework_v2/scripts/convert_to_viewer_csv.py b/framework_v2/scripts/convert_to_viewer_csv.py new file mode 100644 index 0000000..d0fab8e --- /dev/null +++ b/framework_v2/scripts/convert_to_viewer_csv.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +将 V2 回测细节 JSON 转换为 HTML viewer 需要的 CSV 格式。 + +用途: +- 适配 backtest_viewer.html 的 CSV 格式要求 +- 生成 _detail.csv、_close.csv、_holdings.csv、_code_info.csv + +用法: + python framework_v2/scripts/convert_to_viewer_csv.py +""" + +import sys +import json +from pathlib import Path + +import pandas as pd + +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + + +def safe_float(val, decimals=4): + """安全转换浮点数""" + if val is None: + return None + try: + return round(float(val), decimals) + except (ValueError, TypeError): + return None + + +def convert_v2_to_viewer_csv(json_path=None): + """将 V2 JSON 转换为 V1 CSV 格式""" + + # 1. 加载 JSON + if json_path is None: + json_path = project_root / 'framework_v2' / 'results' / 'backtest_detail_v2.json' + + print(f"加载 V2 JSON: {json_path}") + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + meta = data['meta'] + days = data['days'] + + print(f" 天数: {len(days)}") + print(f" 标的: {len(meta['codes'])}") + + # 2. 生成 _detail.csv + print("\n生成 _detail.csv...") + detail_rows = [] + + for day in days: + row = { + '日期': day['date'], + '净值': day['nav'], + '日收益': day['daily_return'], + '持仓标的': ','.join(day['holdings']) if day['holdings'] else '' + } + + # 添加每个标的的动量 + for code, asset in day['assets'].items(): + row[f'动量_{code}'] = asset.get('momentum') + + detail_rows.append(row) + + detail_df = pd.DataFrame(detail_rows) + detail_path = json_path.parent / 'backtest_detail_v2_detail.csv' + detail_df.to_csv(detail_path, index=False, encoding='utf-8-sig') + print(f" ✅ {detail_path.name} ({len(detail_df)} 行)") + + # 3. 生成 _close.csv + print("\n生成 _close.csv...") + close_rows = [] + + for day in days: + row = {'日期': day['date']} + + for code, asset in day['assets'].items(): + # ETF 收盘价 + etf_close = asset.get('etf_close') + row[f'收盘价_{code}'] = etf_close + + close_rows.append(row) + + close_df = pd.DataFrame(close_rows) + close_path = json_path.parent / 'backtest_detail_v2_close.csv' + close_df.to_csv(close_path, index=False, encoding='utf-8-sig') + print(f" ✅ {close_path.name} ({len(close_df)} 行)") + + # 4. 生成 _holdings.csv + print("\n生成 _holdings.csv...") + holdings_rows = [] + + for day in days: + if not day['holdings']: + continue + + for code in day['holdings']: + asset = day['assets'].get(code, {}) + + code_meta = meta['codes'].get(code, {}) + + row = { + '日期': day['date'], + '标的代码': code, + '标的名称': code_meta.get('name', code), + '大类': code_meta.get('group', ''), + '进场日期': asset.get('entry_date'), + '进场价': asset.get('entry_price'), + '当前价': asset.get('etf_close'), + '持仓天数': asset.get('holding_days', 0), + '持仓比例': asset.get('weight', 0), + '累计收益': asset.get('cum_return') + } + + holdings_rows.append(row) + + holdings_df = pd.DataFrame(holdings_rows) + holdings_path = json_path.parent / 'backtest_detail_v2_holdings.csv' + holdings_df.to_csv(holdings_path, index=False, encoding='utf-8-sig') + print(f" ✅ {holdings_path.name} ({len(holdings_df)} 行)") + + # 5. 生成 _code_info.csv + print("\n生成 _code_info.csv...") + code_info_rows = [] + + for code, meta_info in meta['codes'].items(): + row = { + '代码': code, + '名称': meta_info.get('name', code), + '大类': meta_info.get('group', '') + } + code_info_rows.append(row) + + code_info_df = pd.DataFrame(code_info_rows) + code_info_path = json_path.parent / 'backtest_detail_v2_code_info.csv' + code_info_df.to_csv(code_info_path, index=False, encoding='utf-8-sig') + print(f" ✅ {code_info_path.name} ({len(code_info_df)} 行)") + + # 6. 汇总 + print("\n" + "=" * 80) + print("转换完成!") + print("=" * 80) + print(f"\n输出文件:") + print(f" {detail_path.name}") + print(f" {close_path.name}") + print(f" {holdings_path.name}") + print(f" {code_info_path.name}") + print(f"\n使用方法:") + print(f" 1. 打开 visualization/backtest_viewer.html") + print(f" 2. 选择 'V2 GlobalRotationStrategy (最新)'") + print(f" 3. 自动加载 CSV 文件") + + +if __name__ == '__main__': + convert_v2_to_viewer_csv() diff --git a/framework_v2/scripts/export_backtest_detail.py b/framework_v2/scripts/export_backtest_detail.py index c8b1017..58c682d 100644 --- a/framework_v2/scripts/export_backtest_detail.py +++ b/framework_v2/scripts/export_backtest_detail.py @@ -87,6 +87,24 @@ def main(): trading_calendar = strategy._get_trading_calendar() print(f" A 股交易日: {len(trading_calendar)} 天") + # 准备收盘价和溢价率数据 + print("[7.5] 准备价格和溢价率数据...") + index_close_dict = {} # 指数收盘价 + etf_close_dict = {} # ETF 收盘价 + etf_premium_dict = {} # ETF 溢价率(需要从 API 获取) + + for signal_code, trade_code in signal_to_trade.items(): + # 指数收盘价 + if signal_code in data: + index_close_dict[signal_code] = data[signal_code]['close'] + + # ETF 收盘价 + if trade_code in data: + etf_close_dict[signal_code] = data[trade_code]['close'] # 注意:用 signal_code 作为键 + + # 溢价率暂时设为 None(需要额外 API 支持) + # TODO: 接入 ETF 净值数据计算溢价率 + # 创建对齐器 aligner = CrossMarketAligner(target_calendar=trading_calendar) @@ -192,6 +210,25 @@ def main(): assets = {} all_codes = factor_df.columns.tolist() + # 对齐价格到 A 股日历 + index_close_aligned = {} + etf_close_aligned = {} + + for code in all_codes: + if code in index_close_dict: + index_close_aligned[code] = index_close_dict[code].reindex(common_dates, method='ffill') + if code in etf_close_dict: + etf_close_aligned[code] = etf_close_dict[code].reindex(common_dates, method='ffill') + + # 计算指数和 ETF 收益率 + index_returns = {} + etf_returns = {} + for code in all_codes: + if code in index_close_aligned: + index_returns[code] = index_close_aligned[code].pct_change(fill_method=None) + if code in etf_close_aligned: + etf_returns[code] = etf_close_aligned[code].pct_change(fill_method=None) + for code in all_codes: asset = {} @@ -206,49 +243,75 @@ def main(): asset['threshold'] = safe_val(threshold, 4) asset['above_threshold'] = mom >= threshold if mom is not None else False + # 指数价格 + if code in index_close_aligned: + idx_close = index_close_aligned[code].get(date) + asset['index_close'] = safe_val(idx_close, 2) if pd.notna(idx_close) else None + else: + asset['index_close'] = None + + # ETF 价格 + if code in etf_close_aligned: + etf_close = etf_close_aligned[code].get(date) + asset['etf_close'] = safe_val(etf_close, 3) if pd.notna(etf_close) else None + else: + asset['etf_close'] = None + + # 指数收益率 + if code in index_returns: + idx_ret = index_returns[code].get(date) + asset['index_return'] = safe_val(idx_ret, 6) if pd.notna(idx_ret) else None + else: + asset['index_return'] = None + + # ETF 收益率(兼容 V1 命名:etf_return_ctc) + if code in etf_returns: + etf_ret = etf_returns[code].get(date) + asset['etf_return_ctc'] = safe_val(etf_ret, 6) if pd.notna(etf_ret) else None + else: + asset['etf_return_ctc'] = None + + # 溢价率(暂时为 None) + asset['premium'] = None + # 持仓状态 is_held = code in current_holdings asset['is_held'] = is_held - asset['weight'] = safe_val(pos_row.get(code, 0), 4) if is_held else 0.0 if is_held and code in holdings_state: hs = holdings_state[code] asset['entry_date'] = hs['entry_date'] - asset['entry_price'] = safe_val(hs['entry_price'], 4) + asset['entry_price_etf'] = safe_val(hs['entry_price'], 4) + asset['entry_price_idx'] = None # V2 暂不记录指数进场价 entry_dt = pd.Timestamp(hs['entry_date']) trading_days_held = len(common_dates[(common_dates >= entry_dt) & (common_dates <= date)]) asset['holding_days'] = trading_days_held - # 累计收益 + # 累计收益(区分 ETF 和指数,兼容 V1) if hs['entry_price'] and hs['entry_price'] > 0: if code in close_dict: cur = close_dict[code].get(date) if cur and pd.notna(cur): - asset['cum_return'] = safe_val(float(cur) / hs['entry_price'] - 1, 4) + cum_ret = float(cur) / hs['entry_price'] - 1 + asset['cum_return_etf'] = safe_val(cum_ret, 4) + asset['cum_return_idx'] = safe_val(cum_ret, 4) # V2 暂不区分 else: - asset['cum_return'] = None + asset['cum_return_etf'] = None + asset['cum_return_idx'] = None else: - asset['cum_return'] = None + asset['cum_return_etf'] = None + asset['cum_return_idx'] = None else: - asset['cum_return'] = None - - # 当日收益贡献 - if code in returns_df.columns: - asset['daily_return'] = safe_val(returns_df.loc[date, code], 6) - asset['return_contribution'] = safe_val( - pos_row.get(code, 0) * returns_df.loc[date, code], 6 - ) - else: - asset['daily_return'] = None - asset['return_contribution'] = None + asset['cum_return_etf'] = None + asset['cum_return_idx'] = None else: asset['entry_date'] = None - asset['entry_price'] = None + asset['entry_price_etf'] = None + asset['entry_price_idx'] = None asset['holding_days'] = 0 - asset['cum_return'] = None - asset['daily_return'] = None - asset['return_contribution'] = None + asset['cum_return_etf'] = None + asset['cum_return_idx'] = None assets[code] = asset @@ -269,32 +332,30 @@ def main(): days_list.append(day_record) prev_holdings = current_holdings - # 10. 构建元数据 + # 10. 构建元数据(兼容 V1 格式) codes_meta = {} - for signal_code, asset in config.asset_pools.assets.items(): - codes_meta[signal_code] = { - 'name': asset.name, - 'etf': asset.trade_source, - 'group': asset.group + for code in all_codes: + asset_config = config.asset_pools.assets.get(code) + codes_meta[code] = { + 'name': asset_config.name if asset_config else code, + 'etf': asset_config.trade_source if asset_config else None, + 'market': asset_config.group if asset_config else None # V1 使用 market 字段 } output = { 'meta': { - 'version': 'V2', - 'strategy': 'GlobalRotationStrategy', - 'mode': '指数信号 + ETF收益', + 'mode': 'V2: 指数信号 + ETF收益', 'start_date': common_dates[0].strftime('%Y-%m-%d'), 'end_date': common_dates[-1].strftime('%Y-%m-%d'), 'total_days': len(common_dates), + 'select_num': strategy.select_num, + 'n_days': config.factor.n_days, 'trade_cost': strategy.trade_cost, - 'dynamic_threshold': { + 'bond_threshold': { 'enabled': strategy.use_dynamic_threshold, 'bond_code': bond_code, 'ratio': bond_ratio }, - 'diversified': strategy.diversified, - 'rebalance_count': int(rebalance_count), - 'final_nav': safe_val(equity_curve.iloc[-1], 4), 'codes': codes_meta }, 'days': days_list diff --git a/framework_v2/strategies/rotation/rotation.py b/framework_v2/strategies/rotation/rotation.py index 80e580b..9b9240b 100644 --- a/framework_v2/strategies/rotation/rotation.py +++ b/framework_v2/strategies/rotation/rotation.py @@ -229,9 +229,20 @@ class GlobalRotationStrategy(StrategyBase): top_code = date_factors.idxmax() selected_codes.append(top_code) - # 标记信号 + # 第二步:从所有 group 的 Top 1 中,按动量再选 Top select_num 个 if selected_codes: - signals.loc[date, selected_codes] = 1 + # 获取这些标的的当日因子值 + candidate_factors = factor_df.loc[date][selected_codes].dropna() + + if not candidate_factors.empty: + # 按动量排序,选 Top select_num + if len(candidate_factors) > self.select_num: + final_selected = candidate_factors.nlargest(self.select_num).index.tolist() + else: + final_selected = candidate_factors.index.tolist() + + # 标记信号 + signals.loc[date, final_selected] = 1 return signals.astype(int)