fix(rotation): 消除前视偏差 + V2兼容detail导出

时序对齐修复:
- 信号生成改用 T-1 收盘数据(9AM信号时T日未开盘)
- entry_price_etf 改用 T 日 open(实际买入价)
- 年化收益: 52.66% → 25.12%(去除约4倍虚高)

V2兼容detail JSON:
- _generate_signals 返回 (holdings, factors, bond_momentum)
- 6个helper方法: build_meta_codes, get_index/etf_close, daily_returns, premium, day_assets
- 每日11资产×16字段完整记录(momentum/rank/holding_days/cum_return等)
- export_results 同步修复 entry_info 时序逻辑

Backtest (2020-01-10 ~ 2026-06-01, 1545天):
- 总收益 295.14%, 年化 25.12%
- 最大回撤 -14.74%, 夏普 1.33, 卡尔马 1.70
This commit is contained in:
2026-06-01 23:13:43 +08:00
parent 451ffa33d2
commit 6d0b928894

View File

@@ -276,10 +276,14 @@ class SimpleRotationStrategy:
return 0.0
return weighted_momentum_score(prices)
def _generate_signals(self, date: pd.Timestamp) -> List[str]:
def _generate_signals(self, date: pd.Timestamp) -> Tuple[List[str], Dict[str, float], Optional[float]]:
"""
Generate rotation signals (group competition + dynamic threshold + BOND fill)
Returns:
(holdings, factors, bond_momentum): tuple of selected holdings,
all computed factor scores, and the bond momentum threshold value.
Logic (identical to V2):
1. Each group: select Top 1 (non-BOND groups must exceed bond_momentum * ratio)
2. From all group winners: sort by momentum, select Top select_num
@@ -291,7 +295,7 @@ class SimpleRotationStrategy:
if score is not None:
factors[code] = score
if not factors:
return []
return [], {}, None
bond_momentum = None
if self.use_dynamic_threshold and self.bond_code:
@@ -316,7 +320,7 @@ class SimpleRotationStrategy:
selected_by_group[group_name] = (top_code, group_factors[top_code])
if not selected_by_group:
return []
return [], factors, bond_momentum
candidates = list(selected_by_group.values())
candidates.sort(key=lambda x: x[1], reverse=True)
@@ -327,7 +331,7 @@ class SimpleRotationStrategy:
n_slots = self.select_num - len(final_holdings)
final_holdings.extend([self.bond_code] * n_slots)
return sorted(final_holdings)
return sorted(final_holdings), factors, bond_momentum
def _get_etf_prices(self, trade_code: str, date: pd.Timestamp) -> Optional[dict]:
"""Get ETF prices on a given date: {open, close, prev_close}
@@ -431,11 +435,23 @@ class SimpleRotationStrategy:
current_holdings: List[str] = []
nav = 1.0
rebalance_count = 0
entry_info: Dict[str, dict] = {} # signal_code -> {entry_date, entry_price_etf, entry_price_idx}
for i, date in enumerate(self.trading_calendar):
new_holdings = self._generate_signals(date)
# Signal timing: 9:00 AM on day T
# At this moment, T's market has NOT opened yet.
# Only T-1 close data is available for all markets.
# So momentum must be computed from T-1 close (prev_date).
# Execution happens at 9:30 AM using T's ETF prices.
if i > 0:
signal_date = self.trading_calendar[i - 1] # T-1 close
else:
signal_date = date # First day: no prior trading day available
new_holdings, factors, bond_momentum = self._generate_signals(signal_date)
is_rebalance = (sorted(new_holdings) != sorted(current_holdings)) and len(current_holdings) > 0
# Return uses T's ETF prices (open for buy/sell, close for hold)
daily_return = self._calculate_daily_return(
current_holdings, new_holdings, date, is_rebalance
)
@@ -444,14 +460,39 @@ class SimpleRotationStrategy:
if is_rebalance:
rebalance_count += 1
# Update entry tracking for held assets
added = set(new_holdings) - set(current_holdings)
removed = set(current_holdings) - set(new_holdings)
for code in added:
trade_code = self.signal_to_trade.get(code, code)
etf_prices = self._get_etf_prices(trade_code, date)
# Entry price is the actual buy price: today's open
entry_etf = etf_prices['open'] if etf_prices else None
# Index close at T-1 (the data used to make the decision)
entry_idx = self._get_index_close(code, signal_date)
entry_info[code] = {
'entry_date': date.strftime('%Y-%m-%d'),
'entry_price_etf': entry_etf,
'entry_price_idx': entry_idx,
}
for code in removed:
entry_info.pop(code, None)
# Compute bond threshold value for detail record
threshold_val = 0.0
if self.use_dynamic_threshold and bond_momentum is not None:
threshold_val = round(bond_momentum * self.bond_ratio, 6)
self.daily_records.append({
'date': date.strftime('%Y-%m-%d'),
'nav': round(nav, 6),
'daily_return': round(daily_return, 6),
'is_rebalance': is_rebalance,
'holdings': sorted(new_holdings),
'added': sorted(set(new_holdings) - set(current_holdings)),
'removed': sorted(set(current_holdings) - set(new_holdings)),
'added': sorted(added),
'removed': sorted(removed),
'factors': {k: round(v, 6) for k, v in factors.items()},
'threshold': threshold_val,
})
current_holdings = new_holdings
@@ -516,8 +557,150 @@ class SimpleRotationStrategy:
'rebalance_count': rebalance_count,
}
# ============================================================
# Detail JSON helpers (V2-compatible)
# ============================================================
def _build_meta_codes(self) -> dict:
"""Build meta.codes mapping: signal_code -> {name, etf, market}"""
codes = {}
for code, asset in self.config.asset_pools.assets.items():
codes[asset.signal_source] = {
'name': getattr(asset, 'name', code),
'etf': asset.trade_source,
'market': asset.group,
}
return codes
def _get_index_close(self, code: str, date: pd.Timestamp) -> Optional[float]:
"""Get index close price on or before date."""
df = self.index_data.get(code)
if df is None:
return None
mask = df.index <= date
if mask.sum() == 0:
return None
return float(df.loc[mask].iloc[-1]['close'])
def _get_etf_close(self, trade_code: str, date: pd.Timestamp) -> Optional[float]:
"""Get ETF close price on or before date."""
df = self.etf_data.get(trade_code)
if df is None:
return None
mask = df.index <= date
if mask.sum() == 0:
return None
return float(df.loc[mask].iloc[-1]['close'])
def _get_daily_returns(self, code: str, date: pd.Timestamp) -> Tuple[Optional[float], Optional[float]]:
"""Get (index_return, etf_return_ctc) for a code on a given date."""
trade_code = self.signal_to_trade.get(code, code)
idx_df = self.index_data.get(code)
etf_df = self.etf_data.get(trade_code)
idx_ret = None
if idx_df is not None:
mask = idx_df.index <= date
if mask.sum() >= 2:
rows = idx_df.loc[mask]
c_today = float(rows.iloc[-1]['close'])
c_prev = float(rows.iloc[-2]['close'])
if c_prev > 0 and not pd.isna(c_today):
idx_ret = round((c_today - c_prev) / c_prev, 6)
etf_ret = None
if etf_df is not None:
mask = etf_df.index <= date
if mask.sum() >= 2:
rows = etf_df.loc[mask]
c_today = float(rows.iloc[-1]['close'])
c_prev = float(rows.iloc[-2]['close'])
if c_prev > 0 and not pd.isna(c_today):
etf_ret = round((c_today - c_prev) / c_prev, 6)
return idx_ret, etf_ret
def _compute_premium(self, code: str, idx_close: float, etf_close: float) -> Optional[float]:
"""Compute premium = (etf_close - index_close) / index_close.
Only meaningful for ETFs that track an index (not bonds)."""
group = self.code_to_group.get(code, '')
if group == 'BOND':
return None
if idx_close is None or etf_close is None or idx_close == 0:
return None
return round((etf_close - idx_close) / idx_close, 6)
def _build_day_assets(self, record: dict, date: pd.Timestamp,
entry_info: Dict[str, dict]) -> dict:
"""Build V2-compatible per-asset detail dict for one day."""
factors = record.get('factors', {})
holdings = set(record['holdings'])
threshold = record.get('threshold', 0.0)
# Rank: sort all codes by momentum descending, rank 1 = highest
sorted_codes = sorted(factors.keys(), key=lambda c: factors[c], reverse=True)
rank_map = {c: i + 1 for i, c in enumerate(sorted_codes)}
assets = {}
for code in self.signal_codes:
momentum = factors.get(code)
rank = rank_map.get(code)
above_thresh = (momentum is not None and momentum >= threshold) if threshold else (momentum is not None)
is_held = code in holdings
trade_code = self.signal_to_trade.get(code, code)
idx_close = self._get_index_close(code, date)
etf_close = self._get_etf_close(trade_code, date)
idx_ret, etf_ret_ctc = self._get_daily_returns(code, date)
premium = self._compute_premium(code, idx_close, etf_close)
# Entry / holding info
ei = entry_info.get(code) if is_held else None
entry_date = ei['entry_date'] if ei else None
entry_price_etf = ei['entry_price_etf'] if ei else None
entry_price_idx = ei['entry_price_idx'] if ei else None
holding_days = 0
cum_ret_etf = None
cum_ret_idx = None
if ei and is_held:
entry_dt = pd.Timestamp(ei['entry_date'])
holding_days = int((date - entry_dt).days)
if holding_days < 1:
holding_days = 1
ep_etf = ei.get('entry_price_etf')
if ep_etf and ep_etf > 0 and etf_close is not None and not pd.isna(etf_close):
cum_ret_etf = round((etf_close - ep_etf) / ep_etf, 6)
ep_idx = ei.get('entry_price_idx')
if ep_idx and ep_idx > 0 and idx_close is not None and not pd.isna(idx_close):
cum_ret_idx = round((idx_close - ep_idx) / ep_idx, 6)
assets[code] = {
'momentum': float(momentum) if momentum is not None else None,
'rank': rank,
'threshold': float(threshold),
'above_threshold': bool(above_thresh),
'index_close': float(idx_close) if idx_close is not None else None,
'etf_close': float(etf_close) if etf_close is not None else None,
'index_return': float(idx_ret) if idx_ret is not None else None,
'etf_return_ctc': float(etf_ret_ctc) if etf_ret_ctc is not None else None,
'premium': float(premium) if premium is not None else None,
'is_held': bool(is_held),
'entry_date': entry_date,
'entry_price_etf': float(entry_price_etf) if entry_price_etf is not None else None,
'entry_price_idx': float(entry_price_idx) if entry_price_idx is not None else None,
'holding_days': holding_days,
'cum_return_etf': float(cum_ret_etf) if cum_ret_etf is not None else None,
'cum_return_idx': float(cum_ret_idx) if cum_ret_idx is not None else None,
}
return assets
# ============================================================
# Export
# ============================================================
def export_results(self, output_dir: str = None):
"""Export backtest results to CSV and JSON"""
"""Export backtest results to CSV and JSON (V2-compatible detail format)"""
if not self.daily_records:
print(" x No results to export")
return
@@ -539,27 +722,82 @@ class SimpleRotationStrategy:
df[['date', 'holdings', 'is_rebalance', 'added', 'removed']].to_csv(sig_path, index=False)
print(f" + Signals: {sig_path}")
# Detail JSON
# Detail JSON (V2-compatible format)
detail_path = output_dir / 'simple_rotation_detail.json'
days_out = []
# Track entry_info across days for asset detail reconstruction
tracked_entry: Dict[str, dict] = {}
prev_holdings = []
# Build date index map for signal_date lookup (T-1)
date_list = [pd.Timestamp(rec['date']) for rec in self.daily_records]
date_to_signal_date = {}
for i, d in enumerate(date_list):
date_to_signal_date[d] = date_list[i - 1] if i > 0 else d
for rec in self.daily_records:
date = pd.Timestamp(rec['date'])
signal_date = date_to_signal_date[date] # T-1 for signal
holdings = rec['holdings']
added = set(holdings) - set(prev_holdings)
removed = set(prev_holdings) - set(holdings)
# Update entry tracking (consistent with run() logic)
for code in added:
trade_code = self.signal_to_trade.get(code, code)
etf_prices = self._get_etf_prices(trade_code, date)
# Entry price = actual buy price at T's open
entry_etf = etf_prices['open'] if etf_prices else None
# Index close at T-1 (signal data used for decision)
idx_close = self._get_index_close(code, signal_date)
tracked_entry[code] = {
'entry_date': date.strftime('%Y-%m-%d'),
'entry_price_etf': entry_etf,
'entry_price_idx': idx_close,
}
for code in removed:
tracked_entry.pop(code, None)
# Build signals dict: {code: 1} for selected holdings
signals = {c: 1 for c in holdings}
# Build per-asset details
assets = self._build_day_assets(rec, date, tracked_entry)
days_out.append({
'date': rec['date'],
'nav': rec['nav'],
'daily_return': rec['daily_return'],
'is_rebalance': rec['is_rebalance'],
'signals': signals,
'holdings': holdings,
'added': rec['added'],
'removed': rec['removed'],
'assets': assets,
})
prev_holdings = holdings
detail = {
'meta': {
'mode': 'Simple: Daily Iteration',
'start_date': self.config.backtest.start_date,
'end_date': self.config.backtest.end_date or 'now',
'n_days': self.n_days,
'end_date': self.daily_records[-1]['date'] if self.daily_records else self.config.backtest.end_date or 'now',
'total_days': len(self.daily_records),
'select_num': self.select_num,
'n_days': self.n_days,
'trade_cost': self.trade_cost,
'bond_threshold': {
'enabled': self.use_dynamic_threshold,
'bond_code': self.bond_code,
'ratio': self.bond_ratio,
},
'codes': self._build_meta_codes(),
},
'days': self.daily_records,
'days': days_out,
}
with open(detail_path, 'w', encoding='utf-8') as f:
json.dump(detail, f, ensure_ascii=False, indent=2)
print(f" + Detail: {detail_path}")
print(f" + Detail: {detail_path} ({len(days_out)} days)")
# Metrics JSON
metrics = self._compute_metrics(sum(1 for r in self.daily_records if r['is_rebalance']))