Files
etf/framework_v2/strategies/rotation/rotation.py
aszerW 451ffa33d2 clean(rotation): add simple rotation strategy and remove unused files
New:
- rotation/simple_rotation.py: daily-iteration rotation strategy (584 lines)
- rotation/config_loader.py: standalone config loader
- rotation/config_simple.yaml: 11 assets, 7 groups
- rotation/README_SIMPLE.md: usage guide
- scripts/get_trading_calendar.py: trading calendar fetcher

Removed:
- rotation/example_usage.py, run_strategy.py (replaced by simple_rotation.py)
- rotation/results/ output files (gitignored)
- scripts/verify_*.py, calculate_returns_from_detail.py (one-off scripts)
- scripts/README_TRADING_CALENDAR.md

Backtest result (2020-01-10 ~ 2026-06-01):
- Total return: 1237.6%, Annual: 52.66%
- Max drawdown: -11.71%, Sharpe: 2.50
2026-06-01 22:28:26 +08:00

946 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
全球资产大类轮动策略V2 正式版)
基于动量因子的全球资产轮动策略
- 支持信号-交易分离(指数信号 → ETF收益
- 强制分散化选股(每个 group 只选 1 个)
- 动态短债阈值(标的动量 < 短债动量 → 不持有)
- 溢价过滤(避免买入高溢价 ETF
- 调仓控制rebalance_days + rebalance_threshold
- 交易成本计算trade_cost: 0.1%
"""
import pandas as pd
import numpy as np
from typing import Dict, Optional, Tuple
from datetime import datetime, timedelta
from framework_v2.core.strategy import StrategyBase
from framework_v2.config.schemas import StrategyConfig
from framework_v2.shared.factors import MomentumFactor
from framework_v2.shared.data.alignment import CrossMarketAligner
class GlobalRotationStrategy(StrategyBase):
"""
全球资产大类轮动策略V2 正式版)
策略逻辑:
1. 计算各指数标的动量得分(加权线性回归)
2. 使用动态短债阈值过滤负动量标的
3. 每个 group 内竞争,只选 Top 1强制分散化
4. 溢价过滤:排除溢价率 > 阈值的 ETF
5. 调仓控制:最低持仓天数 + 调仓阈值
6. 等权分配仓位
7. 扣除交易成本0.1%
示例:
from framework_v2.config import load_config
from framework_v2.strategies.rotation.rotation import GlobalRotationStrategy
config = load_config('config_simple.yaml')
strategy = GlobalRotationStrategy(config)
result = strategy.run()
"""
def __init__(self, config: StrategyConfig):
"""
初始化策略
Args:
config: 策略配置
"""
super().__init__(config)
# 初始化动量因子
self.momentum = MomentumFactor(
n_days=config.factor.n_days,
weighted=(config.factor.type.value == 'weighted_momentum')
)
# 策略参数(从 config 中读取)
rotation_config = config.rotation
self.select_num = rotation_config.select_num if rotation_config else 3
self.diversified = rotation_config.diversified if rotation_config else True
# 动态阈值配置
self.use_dynamic_threshold = False
self.bond_code = None
self.bond_ratio = 1.0
self.fill_bond = True
if rotation_config and rotation_config.threshold:
threshold_config = rotation_config.threshold
if hasattr(threshold_config, 'mode') and threshold_config.mode == 'dynamic':
self.use_dynamic_threshold = True
dynamic_config = threshold_config.dynamic
self.bond_code = dynamic_config.reference
self.bond_ratio = dynamic_config.ratio
# 调仓控制
self.rebalance_days = getattr(rotation_config, 'rebalance_days', 1) if rotation_config else 1
self.rebalance_threshold = getattr(rotation_config, 'rebalance_threshold', 0.0) if rotation_config else 0.0
# 交易成本
self.trade_cost = getattr(config.backtest, 'trade_cost', 0.001) if config.backtest else 0.001
# 溢价控制
self.use_premium_control = False
self.premium_threshold = 0.10 # 默认 10%
if hasattr(config, 'premium_control'):
premium_config = config.premium_control
self.use_premium_control = getattr(premium_config, 'enabled', False)
if self.use_premium_control:
self.premium_threshold = getattr(premium_config, 'default_threshold', 0.10)
def get_codes(self) -> list:
"""
获取标的列表(信号标的 + 交易标的 + 短债)
Returns:
标的代码列表
"""
codes = set()
# 添加所有信号标的
codes.update(self.config.asset_pools.get_signal_codes())
# 添加所有交易标的
codes.update(self.config.asset_pools.get_trade_codes())
# 如果使用动态阈值,添加短债标的
if self.use_dynamic_threshold and self.bond_code:
codes.add(self.bond_code)
return list(codes)
def get_data(self) -> Dict[str, pd.DataFrame]:
"""
获取数据(分别获取指数和 ETF使用不同的复权方式
指数数据:使用 raw原始价格用于信号计算
ETF 数据:使用 hfq后复权价格用于收益计算
Returns:
数据字典 {code: DataFrame}
"""
if self._data_fetcher is None:
self._data_fetcher = self._create_data_fetcher()
# 获取信号→交易映射
signal_to_trade = self.config.asset_pools.get_signal_to_trade_mapping()
# 处理 end_date 为 None 的情况(使用今天)
from datetime import date
start = self.config.backtest.start_date
end = self.config.backtest.end_date
if end is None:
end = date.today().strftime('%Y-%m-%d')
data = {}
# 1. 获取指数数据(信号标的,使用 raw
signal_codes = set(self.config.asset_pools.get_signal_codes())
if self.use_dynamic_threshold and self.bond_code:
signal_codes.add(self.bond_code)
if signal_codes:
print(f"\n[数据] 获取 {len(signal_codes)} 只指数数据adj='raw'...")
try:
index_data = self._data_fetcher.fetch_indices(
codes=list(signal_codes),
start=start,
end=end,
adj='raw' # 指数使用原始价格
)
data.update(index_data)
print(f" ✓ 指数数据: {len(index_data)}")
except Exception as e:
print(f" ✗ 指数数据获取失败: {e}")
# 2. 获取 ETF 数据(交易标的,使用 hfq
trade_codes = list(set(signal_to_trade.values()))
if trade_codes:
print(f"\n[数据] 获取 {len(trade_codes)} 只 ETF 数据adj='hfq'...")
try:
etf_data = self._data_fetcher.fetch_etf(
codes=trade_codes,
start=start,
end=end,
adj='hfq' # ETF 使用后复权价格
)
data.update(etf_data)
print(f" ✓ ETF 数据: {len(etf_data)}")
except Exception as e:
print(f" ✗ ETF 数据获取失败: {e}")
return data
def compute_factors(self, data: Dict[str, pd.DataFrame]) -> Dict[str, pd.Series]:
"""
计算动量因子(只使用信号标的的数据)
Args:
data: 数据字典 {code: DataFrame}
Returns:
因子字典 {signal_source: Series}
"""
factors = {}
# 只使用信号标的计算因子
signal_codes = self.config.asset_pools.get_signal_codes()
for code in signal_codes:
if code not in data:
print(f" 警告: {code} 数据不存在,跳过")
continue
try:
df = data[code]
factor_values = self.momentum.compute(df)
factors[code] = factor_values
except Exception as e:
print(f" 警告: {code} 因子计算失败 - {e}")
continue
# 如果使用动态阈值,计算短债因子
if self.use_dynamic_threshold and self.bond_code and self.bond_code in data:
try:
df = data[self.bond_code]
bond_factor = self.momentum.compute(df)
factors[self.bond_code] = bond_factor
print(f" [阈值] 短债动量因子已计算: {self.bond_code}")
except Exception as e:
print(f" 警告: 短债因子计算失败 - {e}")
return factors
def generate_signals(self, factors: Dict[str, pd.Series]) -> pd.DataFrame:
"""
生成轮动信号(支持动态阈值和强制分散化)
逻辑:
1. 计算动态短债阈值(如果使用)
2. 因子对齐到 A 股日历ffill 填充休市日)
3. 每个 group 内竞争,选 Top 1
4. 溢价过滤(如果启用)
5. 组合所有 group 的选股结果
Args:
factors: 因子字典 {code: Series}
Returns:
信号 DataFrameindex=日期, columns=signal_source, values=1或0
"""
if not factors:
return pd.DataFrame()
# 获取 A 股交易日历
trading_calendar = self._get_trading_calendar()
# 对齐所有因子到 A 股日历关键ffill 填充休市日)
factor_df = pd.DataFrame(factors)
factor_df = factor_df.reindex(trading_calendar).ffill()
# 获取动态短债阈值(如果使用)
bond_threshold = None
if self.use_dynamic_threshold and self.bond_code and self.bond_code in factors:
# 也要对齐到 A 股日历
bond_threshold = factors[self.bond_code].reindex(trading_calendar).ffill()
print(f" [阈值] 使用动态短债阈值: {self.bond_code}")
# 获取溢价率数据(如果启用溢价控制)
premium_data = None
if self.use_premium_control:
premium_data = self._get_premium_data()
print(f" [溢价] 启用溢价过滤,阈值: {self.premium_threshold:.1%}")
# 按 group 分组选股
# 注意signals 的索引现在是 A 股交易日历
signals = pd.DataFrame(index=trading_calendar, columns=factor_df.columns, data=0)
groups = self.config.asset_pools.by_group
for date in factor_df.index:
selected_codes = []
# 获取 BOND 组的动量作为阈值
bond_threshold_value = None
if bond_threshold is not None and date in bond_threshold.index:
bond_threshold_value = bond_threshold.loc[date] * self.bond_ratio
# 对每个 group 独立选股(包括 BOND 组)
for group_name, assets in groups.items():
# 获取该 group 的信号标的
group_signal_codes = [asset.signal_source for asset in assets.values()]
# 获取当日因子值
date_factors = factor_df.loc[date][group_signal_codes].dropna()
if date_factors.empty:
continue
# 应用动态阈值过滤(非 BOND 组需要超过 BOND 动量)
if bond_threshold_value is not None and group_name != 'BOND':
date_factors = date_factors[date_factors >= bond_threshold_value]
if date_factors.empty:
continue
# 应用溢价过滤
if premium_data is not None:
date_factors = self._filter_by_premium(
date_factors, date, premium_data
)
if date_factors.empty:
continue
# 选择 Top 1强制分散化
top_code = date_factors.idxmax()
selected_codes.append(top_code)
# 第二步:从所有 group 的 Top 1 中包括BOND按动量再选 Top select_num 个
if selected_codes:
# 获取这些标的的当日因子值
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()
# 如果选中的不足 select_num用 BOND 填充空余仓位
if self.fill_bond and self.bond_code:
bond_has_data = (self.bond_code in factor_df.columns and
pd.notna(factor_df.loc[date].get(self.bond_code)))
if bond_has_data and self.bond_code not in final_selected:
n_bond_slots = self.select_num - len(final_selected)
for _ in range(n_bond_slots):
final_selected.append(self.bond_code)
# 标记信号
signals.loc[date, final_selected] = 1
return signals.astype(int)
def manage_positions(self, signals: pd.DataFrame) -> pd.DataFrame:
"""
仓位管理(等权分配 + 调仓控制)
Args:
signals: 信号 DataFrame
Returns:
仓位 DataFrame
"""
positions = signals.astype(float).copy()
# 跟踪上次调仓日期
last_rebalance_date = None
for date in positions.index:
signal_row = positions.loc[date].copy()
n_selected = signal_row.sum()
if n_selected == 0:
# 空仓
positions.loc[date] = 0
continue
# 检查是否需要调仓
if last_rebalance_date is not None:
# 检查持仓天数
holding_days = (date - last_rebalance_date).days
if holding_days < self.rebalance_days:
# 未达到最低持仓天数,保持上次仓位
positions.loc[date] = positions.loc[last_rebalance_date]
continue
# 等权分配
positions.loc[date] = signal_row / n_selected
last_rebalance_date = date
return positions
def _execute_backtest(self, positions: pd.DataFrame, data: Dict[str, pd.DataFrame]) -> Dict[str, any]:
"""
执行回测(使用 CrossMarketAligner 进行正确的数据对齐)
Args:
positions: 仓位 DataFrame
data: 数据字典
Returns:
回测结果字典
"""
# 获取信号→交易映射
signal_to_trade = self.config.asset_pools.get_signal_to_trade_mapping()
# 获取 A 股交易日历
print("\n [对齐] 获取 A 股交易日历...")
trading_calendar = self._get_trading_calendar()
print(f" [日历] A 股交易日: {len(trading_calendar)} 天 ({trading_calendar[0]} ~ {trading_calendar[-1]})")
# 创建对齐器
aligner = CrossMarketAligner(target_calendar=trading_calendar)
# 提取交易标的的收盘价,并对齐到 A 股日历
print(" [对齐] 构建可实现价格序列(模拟真实交易)...")
executable_close_dict = {}
for signal_code, trade_code in signal_to_trade.items():
if trade_code in data:
# 提取开盘价和收盘价
etf_df = data[trade_code]
open_series = etf_df['open'].reindex(trading_calendar, method='ffill')
close_series = etf_df['close'].reindex(trading_calendar, method='ffill')
# 默认使用收盘价
exec_close = close_series.copy()
# 检测调仓日,调整价格以反映真实交易
for i in range(1, len(trading_calendar)):
date = trading_calendar[i]
prev_date = trading_calendar[i-1]
# 获取仓位变化
prev_pos = positions.loc[prev_date, signal_code] if signal_code in positions.columns else 0
curr_pos = positions.loc[date, signal_code] if signal_code in positions.columns else 0
# 买入日:修改前一天价格为当日开盘价
# 这样收益率 = (close[t] - open[t]) / open[t] = 日内收益
if pd.isna(prev_pos) or prev_pos == 0:
if pd.notna(curr_pos) and curr_pos > 0:
exec_close.loc[prev_date] = open_series.loc[date]
# 卖出日:不需要修改(因为 positions[t]=0不会计算收益
executable_close_dict[signal_code] = exec_close
else:
print(f" 警告: {trade_code} 数据不存在,跳过")
# 使用 CrossMarketAligner 对齐多标的收益率
# 内部逻辑:先 ffill 价格到 A 股日历,再计算收益率
print(" [对齐] 计算收益率(使用可实现价格)...")
returns_df = aligner.align_multi_asset(executable_close_dict)
print(f" [对齐] 收益率数据: {len(returns_df)} 天, {len(returns_df.columns)} 个标的")
# 对齐 positions 到 A 股日历
# 注意:必须先 reindex 再 ffill因为 reindex(method='ffill') 不会填充已有的 NaN
positions = positions.reindex(trading_calendar)
# 卖出日不向前填充(保持 0
positions = positions.ffill().fillna(0)
# 计算策略收益(仓位加权,无需延迟)
# 因为 positions[t] 已表示 t 日的实际持仓,且价格已调整为可实现价格
strategy_returns = (positions * returns_df).sum(axis=1)
# 扣除交易成本
strategy_returns, rebalance_count = self._apply_trade_cost(
strategy_returns, positions
)
print(f" [成本] 调仓次数: {rebalance_count}, 交易成本: {self.trade_cost:.2%}")
# 计算净值曲线
equity_curve = (1 + strategy_returns).cumprod()
# 检查是否有数据
if len(equity_curve) == 0:
return {
'equity_curve': equity_curve,
'strategy_returns': strategy_returns,
'positions': positions,
'metrics': {
'total_return': 0,
'annual_return': 0,
'max_drawdown': 0,
'sharpe_ratio': 0,
'n_days': 0,
'rebalance_count': 0,
}
}
# 计算绩效指标
total_return = equity_curve.iloc[-1] / equity_curve.iloc[0] - 1
n_days = len(strategy_returns)
annual_return = (1 + total_return) ** (252 / n_days) - 1 if n_days > 0 else 0
# 最大回撤
cumulative_max = equity_curve.cummax()
drawdown = (equity_curve - cumulative_max) / cumulative_max
max_drawdown = drawdown.min()
# 夏普比率
sharpe = strategy_returns.mean() / strategy_returns.std() * np.sqrt(252) if strategy_returns.std() > 0 else 0
return {
'equity_curve': equity_curve,
'strategy_returns': strategy_returns,
'positions': positions,
'metrics': {
'total_return': total_return,
'annual_return': annual_return,
'max_drawdown': max_drawdown,
'sharpe_ratio': sharpe,
'n_days': n_days,
'rebalance_count': rebalance_count,
}
}
def _apply_trade_cost(self, strategy_returns: pd.Series, positions: pd.DataFrame) -> Tuple[pd.Series, int]:
"""
扣除交易成本
Args:
strategy_returns: 策略收益率
positions: 仓位 DataFrame
Returns:
(扣除成本后的收益率, 调仓次数)
"""
if self.trade_cost <= 0:
return strategy_returns, 0
# 检测调仓(持仓变化)
position_changes = (positions != positions.shift(1)).any(axis=1)
rebalance_count = position_changes.sum()
# 扣除交易成本
strategy_returns[position_changes] -= self.trade_cost
return strategy_returns, rebalance_count
def _get_premium_data(self) -> Optional[Dict]:
"""
从已获取的数据中提取溢价率
Returns:
溢价率数据字典 {signal_code: premium_series}
"""
if not hasattr(self, '_data') or self._data is None:
print(" [警告] 数据未加载,无法获取溢价率")
return None
signal_to_trade = self.config.asset_pools.get_signal_to_trade_mapping()
premium_dict = {}
for signal_code, trade_code in signal_to_trade.items():
if trade_code in self._data:
etf_df = self._data[trade_code]
# 从 attrs 中提取溢价率序列
premium_series = etf_df.attrs.get('premium_series', {})
if premium_series:
# 转换为 Series 并确保 DatetimeIndex
premium_s = pd.Series(premium_series)
premium_s.index = pd.to_datetime(premium_s.index)
premium_dict[signal_code] = premium_s
return premium_dict if premium_dict else None
def _filter_by_premium(self, factors: pd.Series, date: pd.Timestamp, premium_data: Dict) -> pd.Series:
"""
溢价过滤
逻辑:如果 ETF 溢价率 > 阈值,则从候选中排除
Args:
factors: 因子 Series
date: 日期
premium_data: 溢价率数据字典
Returns:
过滤后的因子 Series
"""
if premium_data is None:
return factors
filtered_codes = []
for code in factors.index:
if code in premium_data:
# 获取当前日期的溢价率(前向填充)
premium_s = premium_data[code]
premium_before = premium_s[premium_s.index <= date]
if len(premium_before) > 0:
premium_rate = premium_before.iloc[-1]
# 如果溢价率超过阈值,排除该标的
if premium_rate > self.premium_threshold:
print(f" [溢价过滤] {code} 溢价率 {premium_rate:.2%} > 阈值 {self.premium_threshold:.2%},排除")
continue
filtered_codes.append(code)
return factors[filtered_codes] if filtered_codes else pd.Series(dtype=float)
def _get_trading_calendar(self) -> pd.DatetimeIndex:
"""
获取 A 股交易日历
Returns:
A 股交易日历 DatetimeIndex
"""
from datetime import date
# 获取回测区间
start = self.config.backtest.start_date
end = self.config.backtest.end_date
if end is None:
end = date.today().strftime('%Y-%m-%d')
# 创建临时数据获取器来获取交易日历
if self._data_fetcher is None:
self._data_fetcher = self._create_data_fetcher()
try:
# 调用 get_trading_calendar 方法
calendar = self._data_fetcher.get_trading_calendar(
market='A',
start=start,
end=end
)
print(f" [日历] A 股交易日: {len(calendar)} 天 ({calendar[0]} ~ {calendar[-1]})")
return calendar
except Exception as e:
print(f" [警告] 无法获取 A 股交易日历,使用所有日期: {e}")
# 降级方案:使用 pandas 生成工作日
start_dt = pd.Timestamp(start)
end_dt = pd.Timestamp(end)
return pd.date_range(start=start_dt, end=end_dt, freq='B') # 工作日
@staticmethod
def _safe_val(v, decimals=4):
"""安全转换数值,处理 NaN/Inf"""
import math
if v is None or (isinstance(v, float) and (math.isnan(v) or math.isinf(v))):
return None
if isinstance(v, (np.floating, float)):
return round(float(v), decimals)
if isinstance(v, (np.integer, int)):
return int(v)
return v
def _export_backtest_detail(
self,
factors: Dict[str, pd.Series],
signals: pd.DataFrame,
positions: pd.DataFrame,
result: Dict,
output_path: str
):
"""
导出逐日明细到 JSON
Args:
factors: 因子字典
signals: 信号 DataFrame
positions: 仓位 DataFrame
result: 回测结果
output_path: 输出文件路径
"""
import json
from pathlib import Path
# 准备数据
equity_curve = result['equity_curve']
strategy_returns = result['strategy_returns']
trading_calendar = equity_curve.index
# 提取溢价率
premium_dict = self._get_premium_data()
# 准备价格数据
signal_to_trade = self.config.asset_pools.get_signal_to_trade_mapping()
index_close_dict = {}
etf_close_dict = {}
for signal_code, trade_code in signal_to_trade.items():
if signal_code in self._data:
index_close_dict[signal_code] = self._data[signal_code]['close']
if trade_code in self._data:
etf_close_dict[signal_code] = self._data[trade_code]['close']
# 计算收益率(对齐到 A 股日历)
index_return_dict = {}
etf_return_dict = {}
# 构建 ETF 可实现价格序列(与回测一致)
executable_etf_close = {}
for signal_code, trade_code in signal_to_trade.items():
if trade_code in self._data:
etf_df = self._data[trade_code]
open_series = etf_df['open'].reindex(trading_calendar, method='ffill')
close_series = etf_df['close'].reindex(trading_calendar, method='ffill')
# 默认使用 close
exec_close = close_series.copy()
# 检测调仓日,调整价格
for i in range(1, len(trading_calendar)):
date = trading_calendar[i]
prev_date = trading_calendar[i-1]
# 获取仓位变化
prev_pos = positions.loc[prev_date, signal_code] if signal_code in positions.columns else 0
curr_pos = positions.loc[date, signal_code] if signal_code in positions.columns else 0
# 买入日:修改前一天价格为 open
if pd.isna(prev_pos) or prev_pos == 0:
if pd.notna(curr_pos) and curr_pos > 0:
exec_close.loc[prev_date] = open_series.loc[date]
executable_etf_close[signal_code] = exec_close
for signal_code, trade_code in signal_to_trade.items():
# 指数收益率
if signal_code in index_close_dict:
idx_close = index_close_dict[signal_code].reindex(trading_calendar, method='ffill')
idx_return = idx_close.pct_change(fill_method=None).fillna(0)
index_return_dict[signal_code] = idx_return
# ETF 收益率(使用可实现价格)
if signal_code in executable_etf_close:
etf_exec = executable_etf_close[signal_code]
etf_return = etf_exec.pct_change(fill_method=None).fillna(0)
etf_return_dict[signal_code] = etf_return
# 对齐因子
factor_df = pd.DataFrame(factors)
if not isinstance(factor_df.index, pd.DatetimeIndex):
factor_df.index = pd.to_datetime(factor_df.index)
factor_df_aligned = factor_df.reindex(trading_calendar).ffill()
# 对齐价格
positions_aligned = positions.reindex(trading_calendar, method='ffill')
# 持仓状态跟踪
holdings_state = {}
prev_holdings = set()
days_list = []
# 配置信息
bond_code = self.bond_code if self.use_dynamic_threshold else None
bond_ratio = self.bond_ratio
# 逐日构建
for date in trading_calendar:
# 当前持仓
pos_row = positions_aligned.loc[date]
current_holdings = set(pos_row[pos_row > 0].index.tolist())
# 调仓检测
added = list(current_holdings - prev_holdings)
removed = list(prev_holdings - current_holdings)
is_rebalance = len(added) > 0 or len(removed) > 0
# 更新持仓状态
for code in removed:
holdings_state.pop(code, None)
for code in added:
entry_price = None
if code in etf_close_dict:
ep = etf_close_dict[code].reindex(trading_calendar, method='ffill').get(date)
if pd.notna(ep):
entry_price = float(ep)
holdings_state[code] = {
'entry_date': date.strftime('%Y-%m-%d'),
'entry_price': entry_price,
}
# 动量得分和阈值
factor_scores = {}
if date in factor_df_aligned.index:
for code in factor_df_aligned.columns:
v = factor_df_aligned.loc[date, code]
if pd.notna(v):
factor_scores[code] = float(v)
bond_score = factor_scores.get(bond_code) if bond_code else None
threshold = bond_score * bond_ratio if bond_score else 0.0
# 排名(所有标的都参与排名,包括 BOND
groups = self.config.asset_pools.by_group
bond_codes = set(groups.get('BOND', {}).keys())
# 所有标的都参与排名
sorted_codes = sorted(factor_scores.keys(), key=lambda c: factor_scores[c], reverse=True)
rank_map = {c: r + 1 for r, c in enumerate(sorted_codes) if c in factor_scores}
# 构建每标的详情
assets = {}
all_codes = factor_df.columns.tolist()
for code in all_codes:
asset = {}
# 动量相关
mom = factor_scores.get(code)
asset['momentum'] = self._safe_val(mom, 4)
asset['rank'] = rank_map.get(code)
asset['threshold'] = self._safe_val(threshold, 4)
asset['above_threshold'] = mom >= threshold if mom is not None else False
# 价格
if code in index_close_dict:
idx_close = index_close_dict[code].reindex(trading_calendar, method='ffill').get(date)
asset['index_close'] = self._safe_val(idx_close, 2) if pd.notna(idx_close) else None
else:
asset['index_close'] = None
if code in etf_close_dict:
etf_close = etf_close_dict[code].reindex(trading_calendar, method='ffill').get(date)
asset['etf_close'] = self._safe_val(etf_close, 3) if pd.notna(etf_close) else None
else:
asset['etf_close'] = None
# 当日收益率
if code in index_return_dict:
idx_ret = index_return_dict[code].loc[date] if date in index_return_dict[code].index else 0
asset['index_return'] = self._safe_val(idx_ret, 6) if pd.notna(idx_ret) else 0.0
else:
asset['index_return'] = 0.0
if code in etf_return_dict:
etf_ret = etf_return_dict[code].loc[date] if date in etf_return_dict[code].index else 0
asset['etf_return_ctc'] = self._safe_val(etf_ret, 6) if pd.notna(etf_ret) else 0.0
else:
asset['etf_return_ctc'] = 0.0
# 溢价率
if code in premium_dict:
premium_s = premium_dict[code]
if date in premium_s.index:
premium_val = premium_s.loc[date]
asset['premium'] = round(float(premium_val), 4) if pd.notna(premium_val) else None
else:
premium_before = premium_s[premium_s.index <= date]
if len(premium_before) > 0:
asset['premium'] = round(float(premium_before.iloc[-1]), 4)
else:
asset['premium'] = None
else:
asset['premium'] = None
# 持仓状态
is_held = code in current_holdings
asset['is_held'] = is_held
if is_held and code in holdings_state:
hs = holdings_state[code]
asset['entry_date'] = hs['entry_date']
asset['entry_price_etf'] = self._safe_val(hs['entry_price'], 4)
asset['entry_price_idx'] = None
entry_dt = pd.Timestamp(hs['entry_date'])
trading_days_held = len(trading_calendar[(trading_calendar >= entry_dt) & (trading_calendar <= date)])
asset['holding_days'] = trading_days_held
# 累计收益(分别使用 ETF 和指数价格计算)
if hs['entry_price'] and hs['entry_price'] > 0:
# ETF 累计收益
if code in etf_close_dict:
etf_cur = etf_close_dict[code].reindex(trading_calendar, method='ffill').get(date)
if etf_cur and pd.notna(etf_cur):
etf_cum_ret = float(etf_cur) / hs['entry_price'] - 1
asset['cum_return_etf'] = self._safe_val(etf_cum_ret, 4)
else:
asset['cum_return_etf'] = None
else:
asset['cum_return_etf'] = None
# 指数累计收益(独立计算)
if code in index_close_dict:
idx_cur = index_close_dict[code].reindex(trading_calendar, method='ffill').get(date)
idx_entry = index_close_dict[code].reindex(trading_calendar, method='ffill').get(entry_dt)
if idx_cur and idx_entry and pd.notna(idx_entry) and float(idx_entry) > 0:
idx_cum_ret = float(idx_cur) / float(idx_entry) - 1
asset['cum_return_idx'] = self._safe_val(idx_cum_ret, 4)
else:
asset['cum_return_idx'] = None
else:
asset['cum_return_idx'] = None
else:
asset['cum_return_etf'] = None
asset['cum_return_idx'] = None
else:
asset['entry_date'] = None
asset['entry_price_etf'] = None
asset['entry_price_idx'] = None
asset['holding_days'] = 0
asset['cum_return_etf'] = None
asset['cum_return_idx'] = None
assets[code] = asset
# 信号
signal_row = signals.loc[date] if date in signals.index else pd.Series(dtype=float)
active_signals = {code: int(val) for code, val in signal_row.items() if val > 0}
# 构建日记录
day_record = {
'date': date.strftime('%Y-%m-%d'),
'nav': self._safe_val(equity_curve.loc[date], 4),
'daily_return': self._safe_val(strategy_returns.loc[date], 6),
'is_rebalance': is_rebalance,
'signals': active_signals,
'holdings': sorted(list(current_holdings)),
'added': sorted(added),
'removed': sorted(removed),
'assets': assets
}
days_list.append(day_record)
prev_holdings = current_holdings
# 构建元数据
codes_meta = {}
for code in all_codes:
asset_config = self.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
}
output = {
'meta': {
'mode': 'V2: 指数信号 + ETF收益',
'start_date': trading_calendar[0].strftime('%Y-%m-%d'),
'end_date': trading_calendar[-1].strftime('%Y-%m-%d'),
'total_days': len(trading_calendar),
'select_num': self.select_num,
'n_days': self.config.factor.n_days,
'trade_cost': self.trade_cost,
'bond_threshold': {
'enabled': self.use_dynamic_threshold,
'bond_code': bond_code,
'ratio': bond_ratio
},
'codes': codes_meta
},
'days': days_list
}
# 输出
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(output, f, ensure_ascii=False)
file_size_mb = output_path.stat().st_size / 1024 / 1024
print(f" 写入 {output_path}")
print(f" 大小: {file_size_mb:.1f} MB")
print(f" 天数: {len(days_list)}")
print(f" 标的: {len(all_codes)}")