feat: 新增slope_r2因子并切换为默认因子(年化19.84%, 夏普1.14)

- 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全面胜出)
This commit is contained in:
2026-06-06 15:49:22 +08:00
parent 04b858ff09
commit b564a47a1b
5 changed files with 393 additions and 183 deletions

View File

@@ -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):

View File

@@ -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

View File

@@ -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()

View File

@@ -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
}
]
}

View File

@@ -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)