diff --git a/rotation/simple_rotation.py b/rotation/simple_rotation.py index bbb2a70..fb10a51 100644 --- a/rotation/simple_rotation.py +++ b/rotation/simple_rotation.py @@ -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']))