fix: GlobalRotationStrategy select_num 未生效

- 修复分散化选股逻辑:每个 group 选 Top 1 后,需要再按动量排序选 Top select_num
- 之前:所有 group 的 Top 1 都标记为信号(忽略 select_num)
- 现在:先从每个 group 选 Top 1,再从中按动量选 Top select_num 个
- 影响:配置 select_num=3 时,实际持仓 3 只而不是 4 只(group 数量)
This commit is contained in:
2026-05-25 01:23:00 +08:00
parent 2be81ba00d
commit e8e4e9c3ac
3 changed files with 266 additions and 36 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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)