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:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
144
rotation/experiments/factor_comparison.py
Normal file
144
rotation/experiments/factor_comparison.py
Normal 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()
|
||||
45
rotation/experiments/output/factor_comparison_results.json
Normal file
45
rotation/experiments/output/factor_comparison_results.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user