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()
|
trading_calendar = strategy._get_trading_calendar()
|
||||||
print(f" A 股交易日: {len(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)
|
aligner = CrossMarketAligner(target_calendar=trading_calendar)
|
||||||
|
|
||||||
@@ -192,6 +210,25 @@ def main():
|
|||||||
assets = {}
|
assets = {}
|
||||||
all_codes = factor_df.columns.tolist()
|
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:
|
for code in all_codes:
|
||||||
asset = {}
|
asset = {}
|
||||||
|
|
||||||
@@ -206,49 +243,75 @@ def main():
|
|||||||
asset['threshold'] = safe_val(threshold, 4)
|
asset['threshold'] = safe_val(threshold, 4)
|
||||||
asset['above_threshold'] = mom >= threshold if mom is not None else False
|
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
|
is_held = code in current_holdings
|
||||||
asset['is_held'] = is_held
|
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:
|
if is_held and code in holdings_state:
|
||||||
hs = holdings_state[code]
|
hs = holdings_state[code]
|
||||||
asset['entry_date'] = hs['entry_date']
|
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'])
|
entry_dt = pd.Timestamp(hs['entry_date'])
|
||||||
trading_days_held = len(common_dates[(common_dates >= entry_dt) & (common_dates <= date)])
|
trading_days_held = len(common_dates[(common_dates >= entry_dt) & (common_dates <= date)])
|
||||||
asset['holding_days'] = trading_days_held
|
asset['holding_days'] = trading_days_held
|
||||||
|
|
||||||
# 累计收益
|
# 累计收益(区分 ETF 和指数,兼容 V1)
|
||||||
if hs['entry_price'] and hs['entry_price'] > 0:
|
if hs['entry_price'] and hs['entry_price'] > 0:
|
||||||
if code in close_dict:
|
if code in close_dict:
|
||||||
cur = close_dict[code].get(date)
|
cur = close_dict[code].get(date)
|
||||||
if cur and pd.notna(cur):
|
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:
|
else:
|
||||||
asset['cum_return'] = None
|
asset['cum_return_etf'] = None
|
||||||
|
asset['cum_return_idx'] = None
|
||||||
else:
|
else:
|
||||||
asset['cum_return'] = None
|
asset['cum_return_etf'] = None
|
||||||
|
asset['cum_return_idx'] = None
|
||||||
else:
|
else:
|
||||||
asset['cum_return'] = None
|
asset['cum_return_etf'] = None
|
||||||
|
asset['cum_return_idx'] = 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
|
|
||||||
else:
|
else:
|
||||||
asset['entry_date'] = None
|
asset['entry_date'] = None
|
||||||
asset['entry_price'] = None
|
asset['entry_price_etf'] = None
|
||||||
|
asset['entry_price_idx'] = None
|
||||||
asset['holding_days'] = 0
|
asset['holding_days'] = 0
|
||||||
asset['cum_return'] = None
|
asset['cum_return_etf'] = None
|
||||||
asset['daily_return'] = None
|
asset['cum_return_idx'] = None
|
||||||
asset['return_contribution'] = None
|
|
||||||
|
|
||||||
assets[code] = asset
|
assets[code] = asset
|
||||||
|
|
||||||
@@ -269,32 +332,30 @@ def main():
|
|||||||
days_list.append(day_record)
|
days_list.append(day_record)
|
||||||
prev_holdings = current_holdings
|
prev_holdings = current_holdings
|
||||||
|
|
||||||
# 10. 构建元数据
|
# 10. 构建元数据(兼容 V1 格式)
|
||||||
codes_meta = {}
|
codes_meta = {}
|
||||||
for signal_code, asset in config.asset_pools.assets.items():
|
for code in all_codes:
|
||||||
codes_meta[signal_code] = {
|
asset_config = config.asset_pools.assets.get(code)
|
||||||
'name': asset.name,
|
codes_meta[code] = {
|
||||||
'etf': asset.trade_source,
|
'name': asset_config.name if asset_config else code,
|
||||||
'group': asset.group
|
'etf': asset_config.trade_source if asset_config else None,
|
||||||
|
'market': asset_config.group if asset_config else None # V1 使用 market 字段
|
||||||
}
|
}
|
||||||
|
|
||||||
output = {
|
output = {
|
||||||
'meta': {
|
'meta': {
|
||||||
'version': 'V2',
|
'mode': 'V2: 指数信号 + ETF收益',
|
||||||
'strategy': 'GlobalRotationStrategy',
|
|
||||||
'mode': '指数信号 + ETF收益',
|
|
||||||
'start_date': common_dates[0].strftime('%Y-%m-%d'),
|
'start_date': common_dates[0].strftime('%Y-%m-%d'),
|
||||||
'end_date': common_dates[-1].strftime('%Y-%m-%d'),
|
'end_date': common_dates[-1].strftime('%Y-%m-%d'),
|
||||||
'total_days': len(common_dates),
|
'total_days': len(common_dates),
|
||||||
|
'select_num': strategy.select_num,
|
||||||
|
'n_days': config.factor.n_days,
|
||||||
'trade_cost': strategy.trade_cost,
|
'trade_cost': strategy.trade_cost,
|
||||||
'dynamic_threshold': {
|
'bond_threshold': {
|
||||||
'enabled': strategy.use_dynamic_threshold,
|
'enabled': strategy.use_dynamic_threshold,
|
||||||
'bond_code': bond_code,
|
'bond_code': bond_code,
|
||||||
'ratio': bond_ratio
|
'ratio': bond_ratio
|
||||||
},
|
},
|
||||||
'diversified': strategy.diversified,
|
|
||||||
'rebalance_count': int(rebalance_count),
|
|
||||||
'final_nav': safe_val(equity_curve.iloc[-1], 4),
|
|
||||||
'codes': codes_meta
|
'codes': codes_meta
|
||||||
},
|
},
|
||||||
'days': days_list
|
'days': days_list
|
||||||
|
|||||||
@@ -229,9 +229,20 @@ class GlobalRotationStrategy(StrategyBase):
|
|||||||
top_code = date_factors.idxmax()
|
top_code = date_factors.idxmax()
|
||||||
selected_codes.append(top_code)
|
selected_codes.append(top_code)
|
||||||
|
|
||||||
# 标记信号
|
# 第二步:从所有 group 的 Top 1 中,按动量再选 Top select_num 个
|
||||||
if selected_codes:
|
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)
|
return signals.astype(int)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user