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:
158
framework_v2/scripts/convert_to_viewer_csv.py
Normal file
158
framework_v2/scripts/convert_to_viewer_csv.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user