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:
@@ -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']))
|
||||
|
||||
Reference in New Issue
Block a user