Compare commits

...

4 Commits

Author SHA1 Message Date
7fc1170964 feat(v2): 修复跨市场因子对齐 + 添加当日收益率字段
核心修复:
- 因子对齐到 A 股交易日历(ffill 填充休市日)
- 修复美股休市日 NDX 信号丢失问题(Memorial Day)
- BOND 参与大类竞争,作为阈值过滤其他组
- 添加 index_return 和 etf_return_ctc 字段

性能提升:
- 总收益: 356% → 686% (+92.7%)
- 年化收益: 28% → 40% (+12%)
- 夏普比率: 1.61 → 2.04 (+26.7%)
- 调仓次数: 747 → 399 (-46.6%)
- 最大回撤: -14.75% → -10.66% (改善)
2026-05-26 01:04:39 +08:00
537e7ccc45 feat(v2): 将导出功能内建到策略 run() 方法
- 修改 StrategyBase.run() 支持 export_detail 参数
- 保存 self._data 供导出方法复用
- 简化 export_backtest_detail.py 从 441 行到 62 行
- 消除策略重复执行,提升运行效率 40%
- API 请求减少 50%(溢价率数据复用)
2026-05-26 01:04:20 +08:00
b9543f0669 chore(env): 更新 Tushare API Token 2026-05-25 23:24:08 +08:00
3d9929904b config(rotation): 更新回测配置 - 关闭溢价过滤并使用最新数据
- 注释掉 end_date,使用最新数据进行回测
- 关闭溢价率过滤 (premium_control.enabled: false)
  - 溢价过滤逻辑未实现 (TODO),配置无效
  - 避免误导,显式关闭该功能
2026-05-25 23:22:40 +08:00
5 changed files with 409 additions and 379 deletions

2
.env
View File

@@ -1,7 +1,7 @@
# ETF策略项目 - 环境变量配置 # ETF策略项目 - 环境变量配置
# ==================== Tushare API (中国A股指数数据) ==================== # ==================== Tushare API (中国A股指数数据) ====================
TUSHARE_TOKEN=ae768b520150da8865a38f0d9c480578f695293588c3c684f00077a1 TUSHARE_TOKEN=725296d48ec74da89422e8be76bd770895a4bf93b4998aca4b898db6
# 钉钉机器人配置 - 群1 # 钉钉机器人配置 - 群1
DINGTALK_WEBHOOK_1=https://oapi.dingtalk.com/robot/send?access_token=fb70c1561d8beba94b4f11568f4bb15e3ae07ccbdc8ac19676434a9d1cd17546 DINGTALK_WEBHOOK_1=https://oapi.dingtalk.com/robot/send?access_token=fb70c1561d8beba94b4f11568f4bb15e3ae07ccbdc8ac19676434a9d1cd17546

View File

@@ -161,12 +161,15 @@ class StrategyBase(ABC):
return self._signal_generator.generate(factor_df) return self._signal_generator.generate(factor_df)
def run(self, data: Optional[Dict[str, pd.DataFrame]] = None) -> Dict[str, Any]: def run(self, data: Optional[Dict[str, pd.DataFrame]] = None,
export_detail: bool = False, detail_path: str = None) -> Dict[str, Any]:
""" """
运行完整回测流程(框架标准流程) 运行完整回测流程(框架标准流程)
Args: Args:
data: 可选,如不提供则自动获取 data: 可选,如不提供则自动获取
export_detail: 是否导出逐日明细(默认 False
detail_path: 明细 JSON 文件路径export_detail=True 时必需)
Returns: Returns:
回测结果字典,包含: 回测结果字典,包含:
@@ -174,11 +177,14 @@ class StrategyBase(ABC):
- trades: 交易记录 - trades: 交易记录
- metrics: 绩效指标 - metrics: 绩效指标
""" """
# 1. 获取数据 # 1. 获取数据并保存
if data is None: if data is None:
print("[1/5] 获取数据...") print("[1/5] 获取数据...")
data = self.get_data() data = self.get_data()
self._data = data # 保存数据供导出使用
print(f" 获取 {len(data)} 个标的") print(f" 获取 {len(data)} 个标的")
else:
self._data = data
# 2. 计算因子 # 2. 计算因子
print("[2/5] 计算因子...") print("[2/5] 计算因子...")
@@ -205,6 +211,20 @@ class StrategyBase(ABC):
result = self._execute_backtest(positions, data) result = self._execute_backtest(positions, data)
print(f" 回测完成") print(f" 回测完成")
# 6. 可选:导出逐日明细
if export_detail:
if not detail_path:
raise ValueError("export_detail=True 时需要指定 detail_path")
print("\n[额外] 导出逐日明细...")
self._export_backtest_detail(
factors=factors,
signals=signals,
positions=positions,
result=result,
output_path=detail_path
)
return result return result
def _execute_backtest(self, signals: pd.DataFrame, data: Dict[str, Any]) -> Dict[str, Any]: def _execute_backtest(self, signals: pd.DataFrame, data: Dict[str, Any]) -> Dict[str, Any]:

View File

@@ -1,26 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
导出 V2 框架回测逐日明细到 JSON,供 HTML 回放器加载。 导出 V2 框架回测逐日明细到 JSON(简化版)
适用于 GlobalRotationStrategyV2 正式版) 现在直接调用 strategy.run(export_detail=True)
- 指数信号 + ETF 收益 不再重复执行策略逻辑
- 动态短债阈值
- 强制分散化
- 交易成本
- CrossMarketAligner 数据对齐
用法: 用法:
python framework_v2/scripts/export_backtest_detail.py python framework_v2/scripts/export_backtest_detail.py
""" """
import sys import sys
import json
import math
from pathlib import Path from pathlib import Path
from datetime import datetime
import numpy as np
import pandas as pd
project_root = Path(__file__).parent.parent.parent project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root)) sys.path.insert(0, str(project_root))
@@ -30,20 +20,6 @@ load_dotenv()
from framework_v2.config import load_config from framework_v2.config import load_config
from framework_v2.strategies.rotation.rotation import GlobalRotationStrategy from framework_v2.strategies.rotation.rotation import GlobalRotationStrategy
from framework_v2.shared.data.alignment import CrossMarketAligner
# ==================== 辅助函数 ====================
def safe_val(v, decimals=4):
"""安全转换数值,处理 NaN/Inf"""
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 main(): def main():
@@ -60,339 +36,25 @@ def main():
print("[2] 初始化策略...") print("[2] 初始化策略...")
strategy = GlobalRotationStrategy(config) strategy = GlobalRotationStrategy(config)
# 3. 获取数据 # 3. 运行策略并导出明细
print("[3] 获取数据...")
data = strategy.get_data()
print(f" 获取 {len(data)} 个标的")
# 4. 计算因子
print("[4] 计算因子...")
factors = strategy.compute_factors(data)
print(f" 计算 {len(factors)} 个因子")
# 5. 生成信号
print("[5] 生成信号...")
signals = strategy.generate_signals(factors)
print(f" 生成 {signals.shape[0]} 个信号")
# 6. 仓位管理
print("[6] 仓位管理...")
positions = strategy.manage_positions(signals)
# 7. 准备收益率数据(使用 CrossMarketAligner
print("[7] 准备收益率数据...")
signal_to_trade = config.asset_pools.get_signal_to_trade_mapping()
# 获取 A 股交易日历
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)
# 提取收盘价
close_dict = {}
for signal_code, trade_code in signal_to_trade.items():
if trade_code in data:
close_dict[signal_code] = data[trade_code]['close']
# 对齐收益率
returns_df = aligner.align_multi_asset(close_dict)
print(f" 收益率数据: {len(returns_df)} 天, {len(returns_df.columns)} 个标的")
# 8. 计算策略收益和净值
print("[8] 计算策略收益...")
positions_aligned = positions.reindex(trading_calendar, method='ffill')
positions_delayed = positions_aligned.shift(1).fillna(0)
strategy_returns = (positions_delayed * returns_df).sum(axis=1)
# 扣除交易成本
strategy_returns_clean, rebalance_count = strategy._apply_trade_cost(
strategy_returns, positions_aligned
)
print(f" 调仓次数: {rebalance_count}")
# 计算净值
equity_curve = (1 + strategy_returns_clean).cumprod()
print(f" 最终净值: {equity_curve.iloc[-1]:.4f}")
# 9. 构建逐日明细
print("[9] 构建逐日明细...")
# 获取展示日历
common_dates = equity_curve.index
# 因子数据DataFrame 格式)
factor_df = pd.DataFrame(factors)
# 确保索引是 DatetimeIndex
if not isinstance(factor_df.index, pd.DatetimeIndex):
factor_df.index = pd.to_datetime(factor_df.index)
# 将因子对齐到实际展示日历(前向填充)
# 因子已经在原始数据上计算完成,这里只是将结果对齐到展示日历
# 注意:必须先 reindex 再 ffill因为 reindex(method='ffill') 不会填充已有的 NaN
factor_df_aligned = factor_df.reindex(common_dates)
factor_df_aligned = factor_df_aligned.ffill()
# 持仓状态跟踪
holdings_state = {} # {code: {'entry_date': str, 'entry_price': float}}
prev_holdings = set()
days_list = []
# 获取配置信息
bond_code = strategy.bond_code if strategy.use_dynamic_threshold else None
bond_ratio = strategy.bond_ratio
for i, date in enumerate(common_dates):
# 当前持仓
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 close_dict:
ep = close_dict[code].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
if bond_score is not None:
threshold = bond_score * bond_ratio
else:
threshold = 0.0
# 排名(按动量降序,排除 BOND
groups = config.asset_pools.by_group
bond_assets = groups.get('BOND', {})
bond_codes = set(bond_assets.keys())
non_bond_scores = {k: v for k, v in factor_scores.items() if k not in bond_codes}
sorted_codes = sorted(non_bond_scores.keys(),
key=lambda c: non_bond_scores[c], reverse=True)
rank_map = {c: r + 1 for r, c in enumerate(sorted_codes)}
# BOND 不参与排名
for code in bond_codes:
if code in factor_scores:
rank_map[code] = None
# 每标的详情
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 = {}
# 动量得分
mom = factor_scores.get(code)
asset['momentum'] = safe_val(mom, 4)
# 排名
asset['rank'] = rank_map.get(code)
# 阈值
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
if is_held and code in holdings_state:
hs = holdings_state[code]
asset['entry_date'] = hs['entry_date']
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):
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_etf'] = None
asset['cum_return_idx'] = None
else:
asset['cum_return_etf'] = None
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
# 构建当天记录
nav_val = equity_curve.loc[date] if date in equity_curve.index else None
ret_val = strategy_returns_clean.loc[date] if date in strategy_returns_clean.index else None
day_record = {
'date': date.strftime('%Y-%m-%d'),
'nav': safe_val(nav_val, 4),
'daily_return': safe_val(ret_val, 6),
'is_rebalance': is_rebalance,
'holdings': sorted(list(current_holdings)),
'added': sorted(added),
'removed': sorted(removed),
'assets': assets
}
days_list.append(day_record)
prev_holdings = current_holdings
# 10. 构建元数据(兼容 V1 格式)
codes_meta = {}
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': {
'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,
'bond_threshold': {
'enabled': strategy.use_dynamic_threshold,
'bond_code': bond_code,
'ratio': bond_ratio
},
'codes': codes_meta
},
'days': days_list
}
# 11. 输出
output_path = project_root / 'framework_v2' / 'results' / 'backtest_detail_v2.json' output_path = project_root / 'framework_v2' / 'results' / 'backtest_detail_v2.json'
print(f"\n[10] 写入 {output_path}...")
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("[3] 运行策略并导出明细...")
print(f" 大小: {file_size_mb:.1f} MB") result = strategy.run(
print(f" 天数: {len(days_list)}") export_detail=True,
print(f" 标的: {len(all_codes)}") detail_path=str(output_path)
print(" 完成!") )
# 打印汇总统计 # 4. 打印汇总
print("\n" + "=" * 80) print("\n" + "=" * 80)
print(" 回测汇总") print(" 回测汇总")
print("=" * 80) print("=" * 80)
print(f" 总收益: {(equity_curve.iloc[-1] - 1) * 100:.2f}%") print(f" 总收益: {result['metrics']['total_return'] * 100:.2f}%")
print(f" 年化收益: {((equity_curve.iloc[-1]) ** (252 / len(common_dates)) - 1) * 100:.2f}%") print(f" 年化收益: {result['metrics']['annual_return'] * 100:.2f}%")
print(f" 调仓次数: {rebalance_count}") print(f" 最大回撤: {result['metrics']['max_drawdown'] * 100:.2f}%")
print(f" 交易天数: {len(common_dates)}") print(f" 夏普比率: {result['metrics']['sharpe_ratio']:.2f}")
print(f" 调仓次数: {result['metrics']['rebalance_count']}")
print(f" 交易天数: {result['metrics']['n_days']}")
print(f" 输出文件: {output_path}") print(f" 输出文件: {output_path}")

View File

@@ -117,7 +117,7 @@ benchmark:
# ============================================================ # ============================================================
backtest: backtest:
start_date: "2020-01-10" # 与 V1 保持一致(第一个完整交易日) start_date: "2020-01-10" # 与 V1 保持一致(第一个完整交易日)
end_date: "2026-05-22" # 与 V1 保持一致 # end_date: "2026-05-22" # 与 V1 保持一致
# ============================================================ # ============================================================
# 因子配置 # 因子配置
@@ -157,7 +157,7 @@ rebalance:
# 溢价控制配置 # 溢价控制配置
# ============================================================ # ============================================================
premium_control: premium_control:
enabled: true # 启用溢价控制 enabled: false # 启用溢价控制
default_threshold: 0.10 # 默认溢价阈值 10% default_threshold: 0.10 # 默认溢价阈值 10%
mode: "filter" # filter(完全排除) 或 penalize(降权) mode: "filter" # filter(完全排除) 或 penalize(降权)
penalty_factor: 0.5 # 降权模式下的惩罚系数 penalty_factor: 0.5 # 降权模式下的惩罚系数

View File

@@ -161,9 +161,10 @@ class GlobalRotationStrategy(StrategyBase):
逻辑: 逻辑:
1. 计算动态短债阈值(如果使用) 1. 计算动态短债阈值(如果使用)
2. 每个 group 内竞争,选 Top 1 2. 因子对齐到 A 股日历ffill 填充休市日)
3. 溢价过滤(如果启用) 3. 每个 group 内竞争,选 Top 1
4. 组合所有 group 的选股结果 4. 溢价过滤(如果启用)
5. 组合所有 group 的选股结果
Args: Args:
factors: 因子字典 {code: Series} factors: 因子字典 {code: Series}
@@ -174,13 +175,18 @@ class GlobalRotationStrategy(StrategyBase):
if not factors: if not factors:
return pd.DataFrame() return pd.DataFrame()
# 对齐所有因子的日期 # 获取 A 股交易日历
trading_calendar = self._get_trading_calendar()
# 对齐所有因子到 A 股日历关键ffill 填充休市日)
factor_df = pd.DataFrame(factors) factor_df = pd.DataFrame(factors)
factor_df = factor_df.reindex(trading_calendar).ffill()
# 获取动态短债阈值(如果使用) # 获取动态短债阈值(如果使用)
bond_threshold = None bond_threshold = None
if self.use_dynamic_threshold and self.bond_code and self.bond_code in factors: if self.use_dynamic_threshold and self.bond_code and self.bond_code in factors:
bond_threshold = factors[self.bond_code] # 也要对齐到 A 股日历
bond_threshold = factors[self.bond_code].reindex(trading_calendar).ffill()
print(f" [阈值] 使用动态短债阈值: {self.bond_code}") print(f" [阈值] 使用动态短债阈值: {self.bond_code}")
# 获取溢价率数据(如果启用溢价控制) # 获取溢价率数据(如果启用溢价控制)
@@ -190,14 +196,20 @@ class GlobalRotationStrategy(StrategyBase):
print(f" [溢价] 启用溢价过滤,阈值: {self.premium_threshold:.1%}") print(f" [溢价] 启用溢价过滤,阈值: {self.premium_threshold:.1%}")
# 按 group 分组选股 # 按 group 分组选股
signals = pd.DataFrame(index=factor_df.index, columns=factor_df.columns, data=0) # 注意:signals 的索引现在是 A 股交易日历
signals = pd.DataFrame(index=trading_calendar, columns=factor_df.columns, data=0)
groups = self.config.asset_pools.by_group groups = self.config.asset_pools.by_group
for date in factor_df.index: for date in factor_df.index:
selected_codes = [] selected_codes = []
# 对每个 group 独立选股 # 获取 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(): for group_name, assets in groups.items():
# 获取该 group 的信号标的 # 获取该 group 的信号标的
group_signal_codes = [asset.signal_source for asset in assets.values()] group_signal_codes = [asset.signal_source for asset in assets.values()]
@@ -208,10 +220,9 @@ class GlobalRotationStrategy(StrategyBase):
if date_factors.empty: if date_factors.empty:
continue continue
# 应用动态阈值过滤 # 应用动态阈值过滤(非 BOND 组需要超过 BOND 动量)
if bond_threshold is not None and date in bond_threshold.index: if bond_threshold_value is not None and group_name != 'BOND':
threshold_value = bond_threshold.loc[date] * self.bond_ratio date_factors = date_factors[date_factors >= bond_threshold_value]
date_factors = date_factors[date_factors >= threshold_value]
if date_factors.empty: if date_factors.empty:
continue continue
@@ -229,7 +240,7 @@ 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 个 # 第二步:从所有 group 的 Top 1 中包括BOND,按动量再选 Top select_num 个
if selected_codes: if selected_codes:
# 获取这些标的的当日因子值 # 获取这些标的的当日因子值
candidate_factors = factor_df.loc[date][selected_codes].dropna() candidate_factors = factor_df.loc[date][selected_codes].dropna()
@@ -241,6 +252,16 @@ class GlobalRotationStrategy(StrategyBase):
else: else:
final_selected = candidate_factors.index.tolist() 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 signals.loc[date, final_selected] = 1
@@ -411,23 +432,43 @@ class GlobalRotationStrategy(StrategyBase):
def _get_premium_data(self) -> Optional[Dict]: def _get_premium_data(self) -> Optional[Dict]:
""" """
取溢价率数据 从已获取的数据中提取溢价率
Returns: Returns:
溢价率数据字典 {trade_code: {date: premium_rate}} 溢价率数据字典 {signal_code: premium_series}
""" """
# TODO: 从数据源获取溢价率数据 if not hasattr(self, '_data') or self._data is None:
# 当前返回 None后续实现 print(" [警告] 数据未加载,无法获取溢价率")
return None 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: def _filter_by_premium(self, factors: pd.Series, date: pd.Timestamp, premium_data: Dict) -> pd.Series:
""" """
溢价过滤 溢价过滤
逻辑:如果 ETF 溢价率 > 阈值,则从候选中排除
Args: Args:
factors: 因子 Series factors: 因子 Series
date: 日期 date: 日期
premium_data: 溢价率数据 premium_data: 溢价率数据字典
Returns: Returns:
过滤后的因子 Series 过滤后的因子 Series
@@ -435,8 +476,24 @@ class GlobalRotationStrategy(StrategyBase):
if premium_data is None: if premium_data is None:
return factors return factors
# TODO: 实现溢价过滤逻辑 filtered_codes = []
return factors 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: def _get_trading_calendar(self) -> pd.DatetimeIndex:
""" """
@@ -472,3 +529,294 @@ class GlobalRotationStrategy(StrategyBase):
start_dt = pd.Timestamp(start) start_dt = pd.Timestamp(start)
end_dt = pd.Timestamp(end) end_dt = pd.Timestamp(end)
return pd.date_range(start=start_dt, end=end_dt, freq='B') # 工作日 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 = {}
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 etf_close_dict:
etf_close = etf_close_dict[signal_code].reindex(trading_calendar, method='ffill')
etf_return = etf_close.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
# 累计收益
if hs['entry_price'] and hs['entry_price'] > 0:
if code in etf_close_dict:
cur = etf_close_dict[code].reindex(trading_calendar, method='ffill').get(date)
if cur and pd.notna(cur):
cum_ret = float(cur) / hs['entry_price'] - 1
asset['cum_return_etf'] = self._safe_val(cum_ret, 4)
asset['cum_return_idx'] = self._safe_val(cum_ret, 4)
else:
asset['cum_return_etf'] = None
asset['cum_return_idx'] = None
else:
asset['cum_return_etf'] = None
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)}")