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:
@@ -86,9 +86,12 @@ $$\text{Score} = \frac{\text{prices}[-1]}{\text{prices}[0]} - 1$$
|
|||||||
| vol_adjusted_momentum | 13.16% | 0.85 | -18.61% | 0.71 | 393 | 55.9% |
|
| vol_adjusted_momentum | 13.16% | 0.85 | -18.61% | 0.71 | 393 | 55.9% |
|
||||||
| **slope_r2(当前默认)** | **19.84%** | **1.14** | **-15.35%** | **1.29** | 394 | 54.1% |
|
| **slope_r2(当前默认)** | **19.84%** | **1.14** | **-15.35%** | **1.29** | 394 | 54.1% |
|
||||||
| momentum | 9.27% | 0.57 | -17.42% | 0.53 | 729 | 53.3% |
|
| momentum | 9.27% | 0.57 | -17.42% | 0.53 | 729 | 53.3% |
|
||||||
|
| standardized_slope | 13.73% | 1.01 | -13.52% | 1.02 | 335 | 54.5% |
|
||||||
|
|
||||||
**结论**:`slope_r2` 全面胜出,年化 +1.48%,夏普 +0.12,回撤改善 +1.01%。
|
**结论**:`slope_r2` 全面胜出,年化 +1.48%,夏普 +0.12,回撤改善 +1.01%。
|
||||||
|
|
||||||
|
> **注**:`standardized_slope`(t-statistic)回撤更小但收益大幅落后(年化 -6.11%),说明统计显著性过滤在高波动资产上过度惩罚趋势信号,不适合本场景(详见 3.3)。
|
||||||
|
|
||||||
### 3.2 数值尺度分析(2024-06-03 截面)
|
### 3.2 数值尺度分析(2024-06-03 截面)
|
||||||
|
|
||||||
| 因子 | 最大值 | 最小正值 | max/min 比值 |
|
| 因子 | 最大值 | 最小正值 | max/min 比值 |
|
||||||
@@ -100,6 +103,31 @@ $$\text{Score} = \frac{\text{prices}[-1]}{\text{prices}[0]} - 1$$
|
|||||||
|
|
||||||
`slope_r2` 的跨资产数值差距仅 31 倍,远小于其他因子的 2000~3000 倍,这是其跨市场可比的根本原因。
|
`slope_r2` 的跨资产数值差距仅 31 倍,远小于其他因子的 2000~3000 倍,这是其跨市场可比的根本原因。
|
||||||
|
|
||||||
|
### 3.3 standardized_slope(t-statistic)实验
|
||||||
|
|
||||||
|
**公式**:
|
||||||
|
$$\text{Score} = \frac{\hat{\beta}}{\text{SE}(\hat{\beta})}, \quad \text{SE}(\hat{\beta}) = \sqrt{\frac{\text{MSE}}{S_{xx}}}$$
|
||||||
|
|
||||||
|
**学术动机**:t-statistic 同时考虑了斜率大小和估计的统计显著性,理论上比 `slope × R²` 更严格。
|
||||||
|
|
||||||
|
**实验结果**:
|
||||||
|
|
||||||
|
| 指标 | slope_r2 | standardized_slope | Δ |
|
||||||
|
|------|---------|-------------------|---|
|
||||||
|
| 年化收益 | 19.84% | 13.73% | **-6.11%** |
|
||||||
|
| 夏普比率 | 1.14 | 1.01 | **-0.12** |
|
||||||
|
| 最大回撤 | -15.35% | -13.52% | +1.83% |
|
||||||
|
| Calmar | 1.29 | 1.02 | -0.27 |
|
||||||
|
| 调仓次数 | 394 | 335 | -59 |
|
||||||
|
|
||||||
|
**失败原因分析**:
|
||||||
|
|
||||||
|
- **绝对度量 vs 相对度量**:`SE(β)` 是绝对度量(量纲同斜率),而 `R²` 是相对度量(无量纲)。在跨资产比较中,SE 对高波动资产(如 CL=F、HSTECH)惩罚过重,即使趋势方向正确,score 也会被压低。
|
||||||
|
- **过度过滤**:调仓次数减少 59 次,说明 t-statistic 把大量"方向对但波动大"的有效信号过滤掉了,反而错失趋势行情。
|
||||||
|
- **数学等价性**:`slope / SE(slope) = slope × √(Sxx / MSE)`,而 `slope × R² = slope × (1 - SS_res/SS_tot)`。前者惩罚的是残差方差绝对值,后者惩罚的是偏离趋势线的比例——后者更适合作为趋势质量因子。
|
||||||
|
|
||||||
|
**结论**:t-statistic 不适合本场景,保持 `slope_r2` 为默认因子。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. slope_r2 胜出的原因分析
|
## 4. slope_r2 胜出的原因分析
|
||||||
@@ -245,18 +273,22 @@ factor:
|
|||||||
| 负价格排除 | 窗口内出现非正价格时返回 None | 低(实际影响极小) |
|
| 负价格排除 | 窗口内出现非正价格时返回 None | 低(实际影响极小) |
|
||||||
| 多窗口融合 | 结合 5/25/60 天信号 | 中 |
|
| 多窗口融合 | 结合 5/25/60 天信号 | 中 |
|
||||||
| 截面 rank | 动量值转截面百分位排名 | 低(slope_r2 已天然可比) |
|
| 截面 rank | 动量值转截面百分位排名 | 低(slope_r2 已天然可比) |
|
||||||
|
| ~~标准化斜率~~ | slope/SE(slope),已验证不适合(详见 3.3) | **已排除** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 附录:实验代码
|
## 附录:实验代码
|
||||||
|
|
||||||
对比实验脚本:`rotation/experiments/factor_comparison.py`
|
对比实验脚本:`rotation/experiments/factor_comparison.py`、`rotation/experiments/std_slope_test.py`
|
||||||
|
|
||||||
运行方式:
|
运行方式:
|
||||||
```bash
|
```bash
|
||||||
cd /Users/aszer/code/etf
|
cd /Users/aszer/code/etf
|
||||||
set -a && source .env && set +a
|
set -a && source .env && set +a
|
||||||
python rotation/experiments/factor_comparison.py
|
python rotation/experiments/factor_comparison.py
|
||||||
|
python rotation/experiments/std_slope_test.py
|
||||||
```
|
```
|
||||||
|
|
||||||
结果输出:`rotation/experiments/output/factor_comparison_results.json`
|
结果输出:
|
||||||
|
- `rotation/experiments/output/factor_comparison_results.json`
|
||||||
|
- `rotation/experiments/output/std_slope_test_results.json`
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class FactorType(str, Enum):
|
|||||||
SLOPE_R2 = "slope_r2"
|
SLOPE_R2 = "slope_r2"
|
||||||
WEIGHTED_MOMENTUM = "weighted_momentum"
|
WEIGHTED_MOMENTUM = "weighted_momentum"
|
||||||
VOL_ADJUSTED_MOMENTUM = "vol_adjusted_momentum"
|
VOL_ADJUSTED_MOMENTUM = "vol_adjusted_momentum"
|
||||||
|
STANDARDIZED_SLOPE = "standardized_slope"
|
||||||
|
|
||||||
|
|
||||||
class PremiumMode(str, Enum):
|
class PremiumMode(str, Enum):
|
||||||
|
|||||||
25
rotation/experiments/output/std_slope_test_results.json
Normal file
25
rotation/experiments/output/std_slope_test_results.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
129
rotation/experiments/std_slope_test.py
Normal file
129
rotation/experiments/std_slope_test.py
Normal 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()
|
||||||
@@ -123,6 +123,39 @@ def slope_r2_score(prices: np.ndarray) -> float:
|
|||||||
return 10000 * slope * r2
|
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:
|
def momentum_score(prices: np.ndarray) -> float:
|
||||||
"""Simple price return: (last / first) - 1"""
|
"""Simple price return: (last / first) - 1"""
|
||||||
if len(prices) < 5:
|
if len(prices) < 5:
|
||||||
@@ -409,6 +442,8 @@ class SimpleRotationStrategy:
|
|||||||
return vol_adjusted_momentum_score(prices)
|
return vol_adjusted_momentum_score(prices)
|
||||||
elif ft == FactorType.SLOPE_R2:
|
elif ft == FactorType.SLOPE_R2:
|
||||||
return slope_r2_score(prices)
|
return slope_r2_score(prices)
|
||||||
|
elif ft == FactorType.STANDARDIZED_SLOPE:
|
||||||
|
return standardized_slope_score(prices)
|
||||||
elif ft == FactorType.MOMENTUM:
|
elif ft == FactorType.MOMENTUM:
|
||||||
return momentum_score(prices)
|
return momentum_score(prices)
|
||||||
return weighted_momentum_score(prices)
|
return weighted_momentum_score(prices)
|
||||||
|
|||||||
Reference in New Issue
Block a user