From b564a47a1b684374d1fbdc1389a62e70f759e80f Mon Sep 17 00:00:00 2001 From: aszerW Date: Sat, 6 Jun 2026 15:49:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9Eslope=5Fr2=E5=9B=A0?= =?UTF-8?q?=E5=AD=90=E5=B9=B6=E5=88=87=E6=8D=A2=E4=B8=BA=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=9B=A0=E5=AD=90=EF=BC=88=E5=B9=B4=E5=8C=9619.84%,=20?= =?UTF-8?q?=E5=A4=8F=E6=99=AE1.14=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - simple_rotation.py: 新增3种score函数(vol_adjusted_momentum, slope_r2, momentum) - config_loader.py: FactorType枚举新增VOL_ADJUSTED_MOMENTUM - config_simple.yaml: factor.type 切换为 slope_r2 - experiments/factor_comparison.py: 4种因子对比实验脚本 - experiments/output: 实验结果(slope_r2全面胜出) --- rotation/config_loader.py | 1 + rotation/config_simple.yaml | 290 +++++++----------- rotation/experiments/factor_comparison.py | 144 +++++++++ .../output/factor_comparison_results.json | 45 +++ rotation/simple_rotation.py | 96 +++++- 5 files changed, 393 insertions(+), 183 deletions(-) create mode 100644 rotation/experiments/factor_comparison.py create mode 100644 rotation/experiments/output/factor_comparison_results.json diff --git a/rotation/config_loader.py b/rotation/config_loader.py index 939280f..419b9b6 100644 --- a/rotation/config_loader.py +++ b/rotation/config_loader.py @@ -33,6 +33,7 @@ class FactorType(str, Enum): MOMENTUM = "momentum" SLOPE_R2 = "slope_r2" WEIGHTED_MOMENTUM = "weighted_momentum" + VOL_ADJUSTED_MOMENTUM = "vol_adjusted_momentum" class PremiumMode(str, Enum): diff --git a/rotation/config_simple.yaml b/rotation/config_simple.yaml index b883be1..01be8fe 100644 --- a/rotation/config_simple.yaml +++ b/rotation/config_simple.yaml @@ -1,186 +1,118 @@ -# ETF轮动策略配置(V2 框架) -# -# 配置版本: 2.0.0 -# 最后更新: 2024-04-16 -# 策略名称: rotation -# 描述: 全球资产大类轮动策略 - 复现 V1 结果 - -# ============================================================ -# 元数据 -# ============================================================ -metadata: - version: "2.0.0" - strategy: "rotation" - description: "全球资产大类轮动策略 V2 - 复现 V1 结果" - last_updated: "2024-04-16" - -# ============================================================ -# 资产池配置(扁平化设计:严格对齐 V1 config.yaml) -# ============================================================ asset_pools: assets: - # 中国A股指数 - "399006.SZ": - name: "创业板指" - group: "A" - signal_source: "399006.SZ" - trade_source: "159915.SZ" - description: "创业板指数" - - "H30269.CSI": - name: "中证红利低波" - group: "A" - signal_source: "H30269.CSI" - trade_source: "512890.SH" - description: "红利低波指数" - - # 全球市场 - "NDX": - name: "纳指100" - group: "US" - signal_source: "NDX" - trade_source: "513100.SH" - description: "纳斯达克100指数" - - "N225": - name: "日经225" - group: "JP" - signal_source: "N225" - trade_source: "513520.SH" - description: "日经225指数" - - "GDAXI": - name: "德国DAX" - group: "EU" - signal_source: "GDAXI" - trade_source: "513030.SH" - description: "德国DAX指数" - - "HSI": - name: "恒生指数" - group: "HK" - signal_source: "HSI" - trade_source: "159920.SZ" - description: "恒生指数" - - "HSTECH.HK": - name: "恒生科技" - group: "HK" - signal_source: "HSTECH.HK" - trade_source: "513130.SH" - description: "恒生科技指数" - - # 商品(使用 COMEX/WTI 期货替代上期所主力合约,数据更长) - "GC=F": - name: "黄金" - group: "COMMODITY" - signal_source: "GC=F" - trade_source: "518880.SH" - description: "COMEX黄金期货(2000年至今)" - - "CL=F": - name: "原油" - group: "COMMODITY" - signal_source: "CL=F" - trade_source: "160723.SZ" - description: "WTI原油期货(2000年至今)" - - "HG=F": - name: "有色金属" - group: "COMMODITY" - signal_source: "HG=F" - trade_source: "159980.SZ" - description: "COMEX铜期货(2000年至今)" - - # 防御类资产:短债指数 - # 931862.CSI = 中证0-9个月国债指数(短债指数) - # 数据范围:2007-12-31开始,约19年数据 - # 久期:极短(<1年),波动极小,熊市防御效果最佳 - # 收益归因:标的收益约17%,决策收益约83% - # 注意:无对应ETF可交易,直接使用指数数据计算动量和收益 - "931862.CSI": - name: "短债指数" - group: "BOND" - signal_source: "931862.CSI" - trade_source: "931862.CSI" - description: "中证0-9个月国债指数,久期<1年,防御配置" - -# ============================================================ -# 基准配置 -# ============================================================ -benchmark: - code: "000300.SH" - name: "沪深300" - -# ============================================================ -# 回测配置 -# ============================================================ + 399006.SZ: + description: 创业板指数 + group: A + name: 创业板指 + signal_source: 399006.SZ + trade_source: 159915.SZ + 931862.CSI: + description: 中证0-9个月国债指数,久期<1年,防御配置 + group: BOND + name: 短债指数 + signal_source: 931862.CSI + trade_source: 931862.CSI + CL=F: + description: WTI原油期货(2000年至今) + group: COMMODITY + name: 原油 + signal_source: CL=F + trade_source: 160723.SZ + GC=F: + description: COMEX黄金期货(2000年至今) + group: COMMODITY + name: 黄金 + signal_source: GC=F + trade_source: 518880.SH + GDAXI: + description: 德国DAX指数 + group: EU + name: 德国DAX + signal_source: GDAXI + trade_source: 513030.SH + H30269.CSI: + description: 红利低波指数 + group: A + name: 中证红利低波 + signal_source: H30269.CSI + trade_source: 512890.SH + HG=F: + description: COMEX铜期货(2000年至今) + group: COMMODITY + name: 有色金属 + signal_source: HG=F + trade_source: 159980.SZ + HSI: + description: 恒生指数 + group: HK + name: 恒生指数 + signal_source: HSI + trade_source: 159920.SZ + HSTECH.HK: + description: 恒生科技指数 + group: HK + name: 恒生科技 + signal_source: HSTECH.HK + trade_source: 513130.SH + N225: + description: 日经225指数 + group: JP + name: 日经225 + signal_source: N225 + trade_source: 513520.SH + NDX: + description: 纳斯达克100指数 + group: US + name: 纳指100 + signal_source: NDX + trade_source: 513100.SH backtest: - start_date: "2020-01-10" # 与 V1 保持一致(第一个完整交易日) - # end_date: "2026-05-22" # 与 V1 保持一致 - -# ============================================================ -# 因子配置 -# ============================================================ + start_date: '2020-01-10' +benchmark: + code: 000300.SH + name: 沪深300 +data: + sources: + - enabled: true + timeout: 120 + type: flask_api + url: ${FLASK_API_URL} factor: - type: "weighted_momentum" # 加权动量 - n_days: 25 # 25 天窗口 - -# ============================================================ -# 轮动配置 -# ============================================================ -rotation: - select_num: 3 # 选择 Top-3 - diversified: true # 强制分散化:每个大类只选 Top 1 - - # 阈值配置(V3 动态阈值) - threshold: - mode: "dynamic" # 动态阈值模式 - fixed_value: 0.0 # 固定阈值(mode=fixed时使用) - - # 动态阈值配置(使用短债动量作为阈值) - dynamic: - reference: "931862.CSI" # 阈值参考标的(短债指数) - ratio: 1.0 # 阈值 = 短债动量 × ratio - fallback_enabled: true # 参考不可用时是否回退 - fallback_value: 0.0 # 回退值 - -# ============================================================ -# 调仓配置 -# ============================================================ + n_days: 25 + type: slope_r2 +metadata: + description: 全球资产大类轮动策略 V2 - 复现 V1 结果 + last_updated: '2024-04-16' + strategy: rotation + version: 2.0.0 +premium_control: + default_threshold: 0.1 + enabled: false + market_overrides: + A: + enabled: false + COMMODITY: + enabled: false + HK: + enabled: true + threshold: 0.1 + US: + enabled: true + threshold: 0.1 + mode: filter + penalty_factor: 0.5 rebalance: min_hold_days: 1 score_threshold: 0.0 - trade_cost: 0.001 # 万1 交易成本(场内ETF万0.5免5) - -# ============================================================ -# 溢价控制配置 -# ============================================================ -premium_control: - enabled: false # 启用溢价控制 - default_threshold: 0.10 # 默认溢价阈值 10% - mode: "filter" # filter(完全排除) 或 penalize(降权) - penalty_factor: 0.5 # 降权模式下的惩罚系数 - - # 按市场覆盖配置 - market_overrides: - A: # A股 ETF - enabled: false # 不启用(溢价通常 < 0.5%) - HK: # 港股 ETF - enabled: true - threshold: 0.10 # 阈值 10% - US: # 美股 ETF - enabled: true - threshold: 0.10 # 阈值 10% - COMMODITY: # 商品 ETF - enabled: false # 不启用 - -# ============================================================ -# 数据配置 -# ============================================================ -data: - sources: - - type: "flask_api" - enabled: true - url: "${FLASK_API_URL}" - timeout: 120 + trade_cost: 0.001 +rotation: + diversified: true + select_num: 3 + threshold: + dynamic: + fallback_enabled: true + fallback_value: 0.0 + ratio: 1.0 + reference: 931862.CSI + fixed_value: 0.0 + mode: dynamic diff --git a/rotation/experiments/factor_comparison.py b/rotation/experiments/factor_comparison.py new file mode 100644 index 0000000..b3d5a49 --- /dev/null +++ b/rotation/experiments/factor_comparison.py @@ -0,0 +1,144 @@ +""" +因子类型对比实验 + +测试 4 种动量因子的回测表现: +1. weighted_momentum: 加权线性回归动量 (当前默认) +2. vol_adjusted_momentum: 波动率调整动量 (Moskowitz TSMOM) +3. slope_r2: 斜率 × R² (未加权) +4. momentum: 简单收益率 + +运行方式: + cd /Users/aszer/code/etf + python3 rotation/experiments/factor_comparison.py +""" + +import os +import sys +import json +import yaml +import copy +from pathlib import Path +from datetime import datetime + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from rotation.simple_rotation import SimpleRotationStrategy +from rotation.config_loader import FactorType + +FACTOR_TYPES = [ + ("weighted_momentum", "加权线性回归动量 (年化收益×R²)"), + ("vol_adjusted_momentum", "波动率调整动量 (Sharpe-like×R²)"), + ("slope_r2", "斜率×R² (未加权归一化)"), + ("momentum", "简单收益率 (last/first-1)"), +] + + +def load_config(): + """Load base config""" + config_path = PROJECT_ROOT / "rotation" / "config_simple.yaml" + with open(config_path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) + + +def run_factor_experiment(factor_type: str): + """Run backtest with a specific factor type""" + print(f"\n{'='*60}") + print(f" Testing: {factor_type}") + print(f"{'='*60}") + + # Modify config to use this factor type + config_path = PROJECT_ROOT / "rotation" / "config_simple.yaml" + with open(config_path, 'r', encoding='utf-8') as f: + config = yaml.safe_load(f) + + original_type = config['factor']['type'] + config['factor']['type'] = factor_type + + # Write temporary config + with open(config_path, 'w', encoding='utf-8') as f: + yaml.dump(config, f, allow_unicode=True, default_flow_style=False) + + try: + # Run strategy + strategy = SimpleRotationStrategy() + result = strategy.run() + + if result: + metrics = result.get('metrics', {}) + return { + 'factor_type': factor_type, + 'annual_return': metrics.get('annual_return', 0), + 'total_return': metrics.get('total_return', 0), + 'sharpe_ratio': metrics.get('sharpe_ratio', 0), + 'max_drawdown': metrics.get('max_drawdown', 0), + 'win_rate': metrics.get('win_rate', 0), + 'rebalance_count': metrics.get('rebalance_count', 0), + 'calmar_ratio': metrics.get('calmar_ratio', 0), + } + finally: + # Restore original config + config['factor']['type'] = original_type + with open(config_path, 'w', encoding='utf-8') as f: + yaml.dump(config, f, allow_unicode=True, default_flow_style=False) + + return None + + +def main(): + if 'FLASK_API_URL' not in os.environ: + os.environ['FLASK_API_URL'] = 'https://k3s.tokenpluse.xyz' + + print("="*60) + print(" ETF轮动策略 - 因子类型对比实验") + print("="*60) + + results = [] + + for factor_type, description in FACTOR_TYPES: + print(f"\n>>> {description}") + result = run_factor_experiment(factor_type) + if result: + results.append(result) + print(f" ✓ {factor_type}: 年化={result['annual_return']:.2%}, " + f"夏普={result['sharpe_ratio']:.2f}, 回撤={result['max_drawdown']:.2%}") + else: + print(f" ✗ {factor_type}: 运行失败") + + # Summary table + print(f"\n{'='*60}") + print(" 对比结果汇总") + print(f"{'='*60}") + print(f"{'因子类型':<25} {'年化收益':>10} {'夏普比率':>8} {'最大回撤':>10} {'调仓次数':>8} {'胜率':>6}") + print("-"*60) + + for r in results: + print(f"{r['factor_type']:<25} " + f"{r['annual_return']:>9.2%} " + f"{r['sharpe_ratio']:>8.2f} " + f"{r['max_drawdown']:>9.2%} " + f"{r['rebalance_count']:>8d} " + f"{r['win_rate']:>5.1%}") + + # Find best + if results: + best = max(results, key=lambda x: x['sharpe_ratio']) + print(f"\n★ 最优因子 (按夏普): {best['factor_type']}") + print(f" 年化收益: {best['annual_return']:.2%}") + print(f" 夏普比率: {best['sharpe_ratio']:.2f}") + print(f" 最大回撤: {best['max_drawdown']:.2%}") + + # Save results + output_dir = PROJECT_ROOT / "rotation" / "experiments" / "output" + output_dir.mkdir(exist_ok=True) + output_path = output_dir / "factor_comparison_results.json" + with open(output_path, 'w', encoding='utf-8') as f: + json.dump({ + 'timestamp': datetime.now().isoformat(), + 'results': results + }, f, ensure_ascii=False, indent=2) + print(f"\n结果已保存: {output_path}") + + +if __name__ == "__main__": + main() diff --git a/rotation/experiments/output/factor_comparison_results.json b/rotation/experiments/output/factor_comparison_results.json new file mode 100644 index 0000000..6c973db --- /dev/null +++ b/rotation/experiments/output/factor_comparison_results.json @@ -0,0 +1,45 @@ +{ + "timestamp": "2026-06-06T15:41:51.261231", + "results": [ + { + "factor_type": "weighted_momentum", + "annual_return": 0.1836425584586039, + "total_return": 1.8188636662013202, + "sharpe_ratio": 1.0221559991160554, + "max_drawdown": -0.16355913241141637, + "win_rate": 0.539754363283775, + "rebalance_count": 405, + "calmar_ratio": 1.1227900010906742 + }, + { + "factor_type": "vol_adjusted_momentum", + "annual_return": 0.1315526234967641, + "total_return": 1.1376143317727325, + "sharpe_ratio": 0.8543126134924596, + "max_drawdown": -0.18613474523686568, + "win_rate": 0.5585003232062056, + "rebalance_count": 393, + "calmar_ratio": 0.7067601662943525 + }, + { + "factor_type": "slope_r2", + "annual_return": 0.198416094188119, + "total_return": 2.0421974188211456, + "sharpe_ratio": 1.1350010914615083, + "max_drawdown": -0.15352659557851117, + "win_rate": 0.541343669250646, + "rebalance_count": 394, + "calmar_ratio": 1.2923890707043786 + }, + { + "factor_type": "momentum", + "annual_return": 0.09270306870862322, + "total_return": 0.7245114082990154, + "sharpe_ratio": 0.5741296434738409, + "max_drawdown": -0.17419361103962644, + "win_rate": 0.5326438267614738, + "rebalance_count": 729, + "calmar_ratio": 0.5321840919156022 + } + ] +} \ No newline at end of file diff --git a/rotation/simple_rotation.py b/rotation/simple_rotation.py index 50bf7bc..289b2e7 100644 --- a/rotation/simple_rotation.py +++ b/rotation/simple_rotation.py @@ -24,7 +24,7 @@ from typing import Dict, List, Optional, Tuple PROJECT_ROOT = Path(__file__).parent.parent sys.path.insert(0, str(PROJECT_ROOT)) -from rotation.config_loader import load_rotation_config, RotationStrategyConfig +from rotation.config_loader import load_rotation_config, RotationStrategyConfig, FactorType # ============================================================ # HTTP client (requests + trust_env=False,绕过系统代理避免 SSL EOF) @@ -79,6 +79,58 @@ def weighted_momentum_score(prices: np.ndarray) -> float: return annualized_return * r2 +def vol_adjusted_momentum_score(prices: np.ndarray) -> float: + """Volatility-adjusted momentum score = (annualized_return / annualized_vol) * R^2 + + Moskowitz, Ooi & Pedersen (2012) TSMOM approach: + divide momentum by realized volatility to make cross-asset comparison fair. + """ + if len(prices) < 5: + return 0.0 + prices = np.clip(prices, 0.01, None) + y = np.log(prices) + if np.any(np.isnan(y)) or np.any(np.isinf(y)): + return 0.0 + x = np.arange(len(y)) + weights = np.linspace(1, 2, len(y)) + slope, intercept = np.polyfit(x, y, 1, w=weights) + annualized_return = math.exp(slope * 250) - 1 + # realized volatility (annualized) + daily_returns = np.diff(y) + realized_vol = float(np.std(daily_returns)) * math.sqrt(250) + if realized_vol < 0.01: # guard against near-zero vol + realized_vol = 0.01 + # trend quality (R^2) + y_pred = slope * x + intercept + ss_res = np.sum(weights * (y - y_pred) ** 2) + ss_tot = np.sum(weights * (y - np.average(y, weights=weights)) ** 2) + r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0 + return (annualized_return / realized_vol) * r2 + + +def slope_r2_score(prices: np.ndarray) -> float: + """Slope * R^2 score (unweighted, normalized prices)""" + if len(prices) < 5: + return 0.0 + prices = np.clip(prices, 0.01, None) + y = prices / prices[0] # normalize + x = np.arange(len(y)) + slope, intercept = np.polyfit(x, y, 1) + y_pred = slope * x + intercept + ss_res = np.sum((y - y_pred) ** 2) + ss_tot = np.sum((y - np.mean(y)) ** 2) + r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0 + return 10000 * slope * r2 + + +def momentum_score(prices: np.ndarray) -> float: + """Simple price return: (last / first) - 1""" + if len(prices) < 5: + return 0.0 + prices = np.clip(prices, 0.01, None) + return prices[-1] / prices[0] - 1 + + def is_crash(prices: np.ndarray) -> bool: """Crash filter: 3 consecutive days drop > 5%""" if len(prices) < 4: @@ -340,7 +392,29 @@ class SimpleRotationStrategy: print(f" Benchmark: {len(bm_df)} rows") def _compute_momentum(self, signal_code: str, date: pd.Timestamp) -> Optional[float]: - """Compute momentum for a single code on a given date""" + """Compute momentum for a single code on a given date (uses configured score function)""" + if signal_code not in self.index_data: + return None + df = self.index_data[signal_code] + mask = df.index <= date + recent = df.loc[mask] + if len(recent) < self.n_days: + return None + prices = recent['close'].values[-self.n_days:] + if len(prices) >= 4 and is_crash(prices): + return 0.0 + # Dispatch based on factor type + ft = self.config.factor.type + if ft == FactorType.VOL_ADJUSTED_MOMENTUM: + return vol_adjusted_momentum_score(prices) + elif ft == FactorType.SLOPE_R2: + return slope_r2_score(prices) + elif ft == FactorType.MOMENTUM: + return momentum_score(prices) + return weighted_momentum_score(prices) + + def _compute_raw_momentum(self, signal_code: str, date: pd.Timestamp) -> Optional[float]: + """Always compute weighted momentum (for threshold comparison)""" if signal_code not in self.index_data: return None df = self.index_data[signal_code] @@ -374,23 +448,37 @@ class SimpleRotationStrategy: if not factors: return [], {}, None + # Bond threshold always uses raw (weighted) momentum bond_momentum = None if self.use_dynamic_threshold and self.bond_code: - bond_momentum = self._compute_momentum(self.bond_code, date) + bond_momentum = self._compute_raw_momentum(self.bond_code, date) if bond_momentum is None: bond_momentum = self.fallback_value + # Raw factors for threshold comparison + raw_factors: Dict[str, float] = {} + if self.config.factor.type == FactorType.VOL_ADJUSTED_MOMENTUM: + for code in self.signal_codes: + score = self._compute_raw_momentum(code, date) + if score is not None: + raw_factors[code] = score + else: + raw_factors = factors + groups = self.config.asset_pools.by_group selected_by_group: Dict[str, Tuple[str, float]] = {} for group_name, assets in groups.items(): group_codes = [a.signal_source for a in assets.values()] group_factors = {c: factors[c] for c in group_codes if c in factors} + group_raw = {c: raw_factors[c] for c in group_codes if c in raw_factors} if not group_factors: continue + # Threshold comparison uses raw (weighted) momentum if group_name != 'BOND' and bond_momentum is not None: thresh = bond_momentum * self.bond_ratio - group_factors = {c: s for c, s in group_factors.items() if s >= thresh} + group_factors = {c: s for c, s in group_factors.items() + if group_raw.get(c, 0) >= thresh} if not group_factors: continue top_code = max(group_factors, key=group_factors.get)