feat: 新增 standardized_slope (t-statistic) 因子并实验验证

- simple_rotation.py: 新增 standardized_slope_score 函数 (slope/SE)
- config_loader.py: FactorType 枚举新增 STANDARDIZED_SLOPE
- 对比实验结果: standardized_slope 年化 13.73% vs slope_r2 19.84%
- 结论: t-statistic 过度惩罚高波动资产的有效趋势信号,不适合本场景
- 文档更新: 动量因子对比调研报告新增 3.3 节详细分析
This commit is contained in:
2026-06-06 16:40:01 +08:00
parent aff04318b1
commit 921f84cb6a
5 changed files with 224 additions and 2 deletions

View File

@@ -34,6 +34,7 @@ class FactorType(str, Enum):
SLOPE_R2 = "slope_r2"
WEIGHTED_MOMENTUM = "weighted_momentum"
VOL_ADJUSTED_MOMENTUM = "vol_adjusted_momentum"
STANDARDIZED_SLOPE = "standardized_slope"
class PremiumMode(str, Enum):

View File

@@ -0,0 +1,25 @@
{
"timestamp": "2026-06-06T16:36:39.736366",
"results": [
{
"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": "standardized_slope",
"annual_return": 0.13732579856023497,
"total_return": 1.2055386092808908,
"sharpe_ratio": 1.0139515617271433,
"max_drawdown": -0.13523854511100616,
"win_rate": 0.5452196382428941,
"rebalance_count": 335,
"calmar_ratio": 1.0154338650087928
}
]
}

View File

@@ -0,0 +1,129 @@
"""
slope_r2 vs standardized_slope 对比实验
测试两种信号质量优化的回测表现:
1. slope_r2: slope × R² (当前默认)
2. standardized_slope: slope / SE(slope) (t-statistic)
运行方式:
cd /Users/aszer/code/etf
set -a && source .env && set +a
python3 rotation/experiments/std_slope_test.py
"""
import os
import sys
import json
import yaml
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
FACTOR_TYPES = [
("slope_r2", "slope_r2 (slope×R², 当前默认)"),
("standardized_slope", "standardized_slope (t-statistic)"),
]
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}")
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
with open(config_path, 'w', encoding='utf-8') as f:
yaml.dump(config, f, allow_unicode=True, default_flow_style=False)
try:
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:
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(" slope_r2 vs standardized_slope 对比实验")
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}: 运行失败")
print(f"\n{'='*60}")
print(" 对比结果汇总")
print(f"{'='*60}")
print(f"{'因子类型':<25} {'年化收益':>10} {'夏普比率':>8} {'最大回撤':>10} {'Calmar':>8} {'调仓次数':>8} {'胜率':>6}")
print("-"*80)
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['calmar_ratio']:>8.2f} "
f"{r['rebalance_count']:>8d} "
f"{r['win_rate']:>5.1%}")
if len(results) >= 2:
base = results[0]
new = results[1]
print(f"\n{'='*60}")
print(" 变化对比 (standardized_slope vs slope_r2)")
print(f"{'='*60}")
print(f" 年化收益: {base['annual_return']:.2%}{new['annual_return']:.2%} "
f"(Δ={new['annual_return']-base['annual_return']:+.2%})")
print(f" 夏普比率: {base['sharpe_ratio']:.2f}{new['sharpe_ratio']:.2f} "
f"(Δ={new['sharpe_ratio']-base['sharpe_ratio']:+.2f})")
print(f" 最大回撤: {base['max_drawdown']:.2%}{new['max_drawdown']:.2%} "
f"(Δ={new['max_drawdown']-base['max_drawdown']:+.2%})")
print(f" 调仓次数: {base['rebalance_count']}{new['rebalance_count']} "
f"(Δ={new['rebalance_count']-base['rebalance_count']:+d})")
output_dir = PROJECT_ROOT / "rotation" / "experiments" / "output"
output_dir.mkdir(exist_ok=True)
output_path = output_dir / "std_slope_test_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

@@ -123,6 +123,39 @@ def slope_r2_score(prices: np.ndarray) -> float:
return 10000 * slope * r2
def standardized_slope_score(prices: np.ndarray) -> float:
"""Standardized slope (t-statistic): slope / SE(slope)
Academic basis:
- Uses normalized prices (p/p[0]) for cross-asset comparability,
consistent with slope_r2_score.
- Divides slope by its standard error, yielding a statistical significance
measure rather than raw magnitude.
- Equivalent to the t-value for H0: slope=0, penalizing noisy trends.
Formula:
SE(slope) = sqrt(MSE / Sxx)
MSE = SS_res / (n - 2)
Sxx = sum((xi - x_bar)^2) = n*(n-1)*(n+1)/12 for x = 0..n-1
"""
n = len(prices)
if n < 5:
return 0.0
prices = np.clip(prices, 0.01, None)
y = prices / prices[0] # normalize
x = np.arange(n)
slope, intercept = np.polyfit(x, y, 1)
y_pred = slope * x + intercept
ss_res = np.sum((y - y_pred) ** 2)
# Standard error of slope
mse = ss_res / (n - 2) # unbiased MSE
sxx = n * (n - 1) * (n + 1) / 12 # sum of squared deviations of x
se_slope = math.sqrt(mse / sxx) if sxx > 0 else 1e-9
if se_slope < 1e-12:
se_slope = 1e-12
return slope / se_slope
def momentum_score(prices: np.ndarray) -> float:
"""Simple price return: (last / first) - 1"""
if len(prices) < 5:
@@ -409,6 +442,8 @@ class SimpleRotationStrategy:
return vol_adjusted_momentum_score(prices)
elif ft == FactorType.SLOPE_R2:
return slope_r2_score(prices)
elif ft == FactorType.STANDARDIZED_SLOPE:
return standardized_slope_score(prices)
elif ft == FactorType.MOMENTUM:
return momentum_score(prices)
return weighted_momentum_score(prices)