Compare commits
5 Commits
49b623931b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 09ecac9e56 | |||
| cabfee20b0 | |||
| d657f8506b | |||
| 6e7087a543 | |||
| 8c3ae2269a |
470
docs/experiments/007_momentum_window_optimization.md
Normal file
470
docs/experiments/007_momentum_window_optimization.md
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
# 实验 007:动量因子回看窗口优化研究
|
||||||
|
|
||||||
|
**实验日期**:2026-06-12
|
||||||
|
**实验目标**:研究动量因子回看窗口(n_days)的选择方法,评估多周期融合对策略表现的影响
|
||||||
|
**实验结论**:多周期融合不适用于本策略,维持 25 天单窗口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、问题背景
|
||||||
|
|
||||||
|
### 1.1 当前配置
|
||||||
|
|
||||||
|
策略使用 `slope_r2` 因子,全局统一 25 天回看窗口:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
factor:
|
||||||
|
n_days: 25
|
||||||
|
type: slope_r2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 发现的问题
|
||||||
|
|
||||||
|
从回测数据的因子分布分析发现:
|
||||||
|
|
||||||
|
| 标的 | IQR(波动代理) | 中位数 | 正得分% | 特征 |
|
||||||
|
|------|----------------|--------|---------|------|
|
||||||
|
| 创业板指 | 28.23 | 0.00 | 51.0% | 高波动,趋势间歇性强 |
|
||||||
|
| 恒生科技 | 20.71 | -0.01 | 44.0% | 高波动,趋势弱 |
|
||||||
|
| 纳指100 | 20.10 | 4.31 | 67.4% | 高波动,趋势强 |
|
||||||
|
| 短债 | 0.38 | 0.68 | 97.9% | 低波动,几乎无趋势 |
|
||||||
|
| 黄金 | 13.39 | 0.77 | 60.7% | 中等波动,趋势持续 |
|
||||||
|
|
||||||
|
**核心问题**:不同资产的趋势周期差异很大,统一 25 天窗口是否合理?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、学界与业界调研
|
||||||
|
|
||||||
|
### 2.1 经典文献的标准做法
|
||||||
|
|
||||||
|
#### 横截面动量(Cross-Sectional Momentum)
|
||||||
|
|
||||||
|
**Jegadeesh & Titman (1993)** 开创性研究:
|
||||||
|
- **标准窗口**:12-1 月(过去12个月收益,跳过最近1个月)
|
||||||
|
- **金融语义**:跳过最近1个月是因为存在短期反转效应(1周-1个月),而中期(3-12个月)存在动量效应
|
||||||
|
- **原理**:信息扩散慢 → 价格对新信息反应不足 → 形成中期趋势
|
||||||
|
|
||||||
|
**Asness, Moskowitz & Pedersen (2013)** "Value and Momentum Everywhere":
|
||||||
|
- 股票:12-1 月
|
||||||
|
- 债券:12-1 月或 6-1 月
|
||||||
|
- 商品:12 月(不跳过,因为商品市场短期反转弱)
|
||||||
|
- 货币:12 月
|
||||||
|
- **核心观点**:不同资产类别的最优窗口不同,但 12 月是一个稳健的起点
|
||||||
|
|
||||||
|
#### 时间序列动量(Time-Series Momentum / TSMOM)
|
||||||
|
|
||||||
|
**Moskowitz, Ooi & Pedersen (2012)**:
|
||||||
|
- **标准窗口**:12 月(用于期货)
|
||||||
|
- **金融语义**:TSMOM 关注资产自身的绝对收益,不与其他资产比较
|
||||||
|
- **关键发现**:1-12 月窗口都有效,但 12 月最稳健
|
||||||
|
- **波动率调整**:用实现波动率标准化头寸规模,使得不同资产可比
|
||||||
|
|
||||||
|
### 2.2 窗口选择的金融语义
|
||||||
|
|
||||||
|
#### 不同窗口对应的市场微观结构
|
||||||
|
|
||||||
|
| 窗口长度 | 捕捉的效应 | 金融解释 | 风险 |
|
||||||
|
|---------|-----------|---------|------|
|
||||||
|
| 1周-1月 | 短期反转 | 流动性冲击、过度反应修正 | 高换手、交易成本 |
|
||||||
|
| 1-3月 | 早期动量 | 信息扩散初期、盈余公告后漂移 | 容易被打断 |
|
||||||
|
| 3-12月 | 经典动量 | 信息扩散慢、机构调仓周期 | 最稳健 |
|
||||||
|
| 12-24月 | 长期动量 | 经济周期、企业基本面变化 | 均值回归开始显现 |
|
||||||
|
| >24月 | 长期反转 | 估值回归、经济周期反转 | 动量效应消失 |
|
||||||
|
|
||||||
|
#### 不同资产类别的特征周期
|
||||||
|
|
||||||
|
| 资产类别 | 特征周期 | 推荐窗口 | 理由 |
|
||||||
|
|---------|---------|---------|------|
|
||||||
|
| **股票指数** | 季度财报+机构调仓 | 6-12月 | 信息扩散慢,机构季度调仓 |
|
||||||
|
| **债券** | 央行政策周期 | 3-6月 | 利率变化快,久期短 |
|
||||||
|
| **商品** | 供需周期+季节性 | 6-12月 | 供需调整慢,但有季节性 |
|
||||||
|
| **货币** | 利差+央行政策 | 3-6月 | 政策变化快 |
|
||||||
|
|
||||||
|
### 2.3 避免过拟合的原则
|
||||||
|
|
||||||
|
#### 先验选择 vs 数据挖掘
|
||||||
|
|
||||||
|
**过拟合的做法**:
|
||||||
|
```python
|
||||||
|
# 在历史数据上测试 5, 10, 15, 20, 25, 30... 天,选最好的
|
||||||
|
for window in [5, 10, 15, 20, 25, 30, 60, 120]:
|
||||||
|
backtest(window)
|
||||||
|
best_window = argmax(results) # 过拟合!
|
||||||
|
```
|
||||||
|
|
||||||
|
**有金融语义的做法**:
|
||||||
|
```python
|
||||||
|
# 基于资产类别选择窗口
|
||||||
|
window_map = {
|
||||||
|
'equity_index': 252, # 12月(252交易日)
|
||||||
|
'bond': 126, # 6月
|
||||||
|
'commodity': 252, # 12月
|
||||||
|
'currency': 126, # 6月
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 稳健性检验原则
|
||||||
|
|
||||||
|
**学界推荐的做法**:
|
||||||
|
1. **选择有理论支撑的窗口**:12月、6月、3月是标准选择
|
||||||
|
2. **测试邻域稳健性**:如果 12 月好,11 月和 13 月也应该不差
|
||||||
|
3. **多窗口平均**:用 3-12 月的多个窗口取平均,降低单窗口风险
|
||||||
|
4. **样本外验证**:在不同时间段、不同市场验证
|
||||||
|
|
||||||
|
**AQR 的实践建议**:
|
||||||
|
- 不要优化到极端值(如 17 天、23 天)
|
||||||
|
- 选择"足够好"的标准窗口(如 252 天而非 247 天)
|
||||||
|
- 关注经济解释而非统计显著性
|
||||||
|
|
||||||
|
### 2.4 多窗口融合方法
|
||||||
|
|
||||||
|
#### 1. 等权平均(Simple Ensemble)
|
||||||
|
|
||||||
|
```python
|
||||||
|
windows = [63, 126, 252] # 3月、6月、12月
|
||||||
|
momentum = mean([return(p, w) for w in windows])
|
||||||
|
```
|
||||||
|
|
||||||
|
**优点**:
|
||||||
|
- 降低单窗口风险
|
||||||
|
- 捕捉不同周期的趋势
|
||||||
|
- 无需优化参数
|
||||||
|
|
||||||
|
**缺点**:
|
||||||
|
- 等权可能不合理
|
||||||
|
- 可能引入噪音窗口
|
||||||
|
|
||||||
|
#### 2. 波动率加权(Volatility-Weighted)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 波动率低的窗口权重更高(更稳定)
|
||||||
|
weights = 1 / vol(window_i)
|
||||||
|
momentum = weighted_mean(momentum_i, weights)
|
||||||
|
```
|
||||||
|
|
||||||
|
**金融语义**:低波动窗口的信号更可靠
|
||||||
|
|
||||||
|
#### 3. 自适应窗口(Adaptive Window)
|
||||||
|
|
||||||
|
**基于波动率的自适应**:
|
||||||
|
```python
|
||||||
|
# 高波动时缩短窗口(快速反应),低波动时延长(过滤噪音)
|
||||||
|
if realized_vol > threshold:
|
||||||
|
window = 63 # 3月
|
||||||
|
else:
|
||||||
|
window = 252 # 12月
|
||||||
|
```
|
||||||
|
|
||||||
|
**基于机制转换(Regime Switching)**:
|
||||||
|
```python
|
||||||
|
# 用 HMM 识别市场状态
|
||||||
|
regime = detect_regime(market_data)
|
||||||
|
if regime == 'trending':
|
||||||
|
window = 252 # 趋势市用长窗口
|
||||||
|
elif regime == 'mean_reverting':
|
||||||
|
window = 21 # 震荡市用短窗口或反转
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 多周期融合为什么有效?
|
||||||
|
|
||||||
|
#### 1. 信息扩散有多个时间尺度
|
||||||
|
|
||||||
|
- **短期(1-4周)**:流动性冲击、技术性买卖、短期情绪(噪音)
|
||||||
|
- **中期(1-6月)**:盈余公告、行业数据、政策变化(信息扩散)
|
||||||
|
- **长期(6-12月)**:经济周期转换、产业趋势、估值重定价
|
||||||
|
|
||||||
|
单一窗口只能捕捉一个尺度的信息。多窗口融合等于同时监听多个信息频段。
|
||||||
|
|
||||||
|
#### 2. 市场参与者的决策周期不同
|
||||||
|
|
||||||
|
| 参与者 | 决策周期 | 影响的价格趋势 |
|
||||||
|
|--------|---------|--------------|
|
||||||
|
| 高频/量化 | 天-周 | 短期噪音 |
|
||||||
|
| 共同基金 | 月-季 | 中期动量 |
|
||||||
|
| 养老金/保险 | 季-年 | 长期趋势 |
|
||||||
|
| 主权基金 | 年+ | 结构性变化 |
|
||||||
|
|
||||||
|
当多个周期的信号一致时,意味着不同时间维度的市场参与者方向一致——这是最强的趋势确认。
|
||||||
|
|
||||||
|
#### 3. 统计角度:偏差-方差权衡
|
||||||
|
|
||||||
|
- **短窗口**:低偏差(快速捕捉趋势变化),高方差(容易被噪音干扰)
|
||||||
|
- **长窗口**:高偏差(趋势转折时反应慢),低方差(噪音被平滑掉)
|
||||||
|
|
||||||
|
多窗口融合相当于对偏差-方差做了平均,短窗口提供灵敏度,长窗口提供稳定性。
|
||||||
|
|
||||||
|
#### 4. 信号处理的类比
|
||||||
|
|
||||||
|
把价格序列想象成一个信号,不同窗口就是不同频率的带通滤波器:
|
||||||
|
|
||||||
|
```
|
||||||
|
价格信号 = 高频噪音 + 中期趋势 + 长期趋势 + 周期性波动
|
||||||
|
|
||||||
|
25天窗口 → 带通滤波器:主要透过高频成分
|
||||||
|
126天窗口 → 带通滤波器:主要透过中期成分
|
||||||
|
252天窗口 → 低通滤波器:只保留长期趋势
|
||||||
|
```
|
||||||
|
|
||||||
|
多个滤波器融合 = 宽频带接收,信息更完整。
|
||||||
|
|
||||||
|
### 2.6 多周期融合对 slope_r2 偏好的影响
|
||||||
|
|
||||||
|
slope_r2 真正偏好的是**趋势性波动高的资产**(高波动+有方向),而不是单纯的高波动。
|
||||||
|
|
||||||
|
多周期融合的预期影响:
|
||||||
|
|
||||||
|
| 资产类型 | 单窗口(25天) | 多周期融合 | 变化方向 |
|
||||||
|
|---------|-------------|-----------|---------|
|
||||||
|
| 高波动+持续趋势(纳指) | 高分 | 高分 | 不变 |
|
||||||
|
| 高波动+间歇趋势(创业板) | 不稳定高分 | 中等分 | **下降** |
|
||||||
|
| 低波动+持续趋势(黄金) | 中等分 | 中等偏高分 | **上升** |
|
||||||
|
| 低波动+无趋势(短债) | 低分 | 低分 | 不变 |
|
||||||
|
| 高波动+无趋势(恒生科技) | 低分 | 低分 | 不变 |
|
||||||
|
|
||||||
|
**核心预期**:融合会让 slope_r2 的偏好从"高波动+趋势性"转向"持续性趋势"——不管波动高低,只要趋势持续就得分高。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、实验设计
|
||||||
|
|
||||||
|
### 3.1 实验一:IDM 信息离散动量融合
|
||||||
|
|
||||||
|
#### 方法
|
||||||
|
|
||||||
|
**IDM(Information Dispersal Momentum)**:正收益天数占比,衡量上涨的持续性
|
||||||
|
|
||||||
|
```python
|
||||||
|
def info_dispersal_momentum(prices: np.ndarray) -> float:
|
||||||
|
returns = np.diff(prices)
|
||||||
|
positive_days = np.sum(returns > 0)
|
||||||
|
return positive_days / len(returns)
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式一:乘法融合**
|
||||||
|
```python
|
||||||
|
def slope_r2_idm_score(prices: np.ndarray) -> float:
|
||||||
|
sr2 = slope_r2_score(prices)
|
||||||
|
idm = info_dispersal_momentum(prices)
|
||||||
|
return sr2 * idm # IDM 作为折扣系数
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式三:阈值过滤**
|
||||||
|
```python
|
||||||
|
def slope_r2_idm_filter_score(prices: np.ndarray, threshold: float = 0.5) -> float:
|
||||||
|
idm = info_dispersal_momentum(prices)
|
||||||
|
if idm < threshold:
|
||||||
|
return 0.0 # 上涨天数不足阈值则清零
|
||||||
|
return slope_r2_score(prices)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 实验配置
|
||||||
|
|
||||||
|
- **方式一**:`type: slope_r2_idm`
|
||||||
|
- **方式三**:`type: slope_r2_idm_filter`,测试阈值 0.4/0.5/0.6
|
||||||
|
|
||||||
|
### 3.2 实验二:多周期融合
|
||||||
|
|
||||||
|
#### 方法
|
||||||
|
|
||||||
|
```python
|
||||||
|
def slope_r2_ensemble_score(prices: np.ndarray, windows: list = None) -> float:
|
||||||
|
if windows is None:
|
||||||
|
windows = [63, 126, 252] # 3月、6月、12月
|
||||||
|
|
||||||
|
scores = []
|
||||||
|
for w in windows:
|
||||||
|
if len(prices) >= w:
|
||||||
|
window_prices = prices[-w:]
|
||||||
|
score = slope_r2_score(window_prices)
|
||||||
|
scores.append(score)
|
||||||
|
|
||||||
|
return sum(scores) / len(scores) if scores else 0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 实验配置
|
||||||
|
|
||||||
|
- **配置**:`type: slope_r2_ensemble`
|
||||||
|
- **窗口**:63/126/252 天(3月/6月/12月)
|
||||||
|
- **数据预加载**:504 天(2倍最大窗口)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、实验结果
|
||||||
|
|
||||||
|
### 4.1 实验一:IDM 融合结果
|
||||||
|
|
||||||
|
#### 方式一:乘法融合
|
||||||
|
|
||||||
|
| 指标 | slope_r2 (baseline) | slope_r2_idm | 变化 |
|
||||||
|
|------|---------------------|--------------|------|
|
||||||
|
| 总收益 | 288.30% | 296.55% | +8.25% |
|
||||||
|
| 年化收益 | 24.61% | 25.03% | +0.42% |
|
||||||
|
| 最大回撤 | -16.27% | -16.19% | 略改善 |
|
||||||
|
| Sharpe | 1.17 | 1.20 | +0.03 |
|
||||||
|
| Calmar | 1.51 | 1.55 | +0.04 |
|
||||||
|
| 胜率 | 53.74% | 54.48% | +0.74% |
|
||||||
|
| 调仓次数 | 363 | 374 | +11 |
|
||||||
|
|
||||||
|
**结论**:方式一(乘法融合)全面小幅优于 baseline。
|
||||||
|
|
||||||
|
#### 方式三:阈值过滤
|
||||||
|
|
||||||
|
| 阈值 | 总收益 | 年化 | 最大回撤 | Sharpe | Calmar | 胜率 |
|
||||||
|
|------|--------|------|----------|--------|--------|------|
|
||||||
|
| 0.4 | 297.88% | 25.10% | -16.27% | 1.19 | 1.54 | 53.87% |
|
||||||
|
| 0.5 | 205.38% | 19.85% | -17.90% | 1.00 | 1.11 | 53.35% |
|
||||||
|
| 0.6 | 69.75% | 8.96% | -24.77% | 0.58 | 0.36 | 56.87% |
|
||||||
|
|
||||||
|
**结论**:方式三(过滤器)阈值敏感,0.5 和 0.6 明显变差,容易过拟合。
|
||||||
|
|
||||||
|
### 4.2 实验二:多周期融合结果
|
||||||
|
|
||||||
|
#### 绩效对比
|
||||||
|
|
||||||
|
| 指标 | slope_r2 (25天) | slope_r2_ensemble (63/126/252) | 变化 |
|
||||||
|
|------|----------------|-------------------------------|------|
|
||||||
|
| 总收益 | 288.30% | 182.82% | **-105.48%** |
|
||||||
|
| 年化收益 | 24.61% | 18.36% | **-6.25%** |
|
||||||
|
| 最大回撤 | -16.27% | -21.61% | **恶化 5.34%** |
|
||||||
|
| Sharpe | 1.17 | 0.96 | **-0.21** |
|
||||||
|
| Calmar | 1.51 | 0.85 | **-0.66** |
|
||||||
|
| 胜率 | 53.74% | 55.47% | +1.73% |
|
||||||
|
| 调仓次数 | 363 | 167 | **-196** |
|
||||||
|
|
||||||
|
#### 持仓频率变化
|
||||||
|
|
||||||
|
| 标的 | baseline 占比 | ensemble 占比 | 变化 | 资产类型 |
|
||||||
|
|------|--------------|--------------|------|---------|
|
||||||
|
| 纳指100 | 44.7% | 53.3% | **+8.6%** | 高波动+持续趋势 |
|
||||||
|
| 黄金 | 21.0% | 35.8% | **+14.8%** | 低波动+持续趋势 |
|
||||||
|
| 创业板指 | 29.9% | 40.2% | **+10.3%** | 高波动+间歇趋势 |
|
||||||
|
| 日经225 | 31.0% | 37.7% | +6.7% | 中波动+持续趋势 |
|
||||||
|
| 德国DAX | 27.8% | 33.1% | +5.3% | 中波动+持续趋势 |
|
||||||
|
| **短债指数** | **32.0%** | **16.6%** | **-15.4%** | 防御填充 |
|
||||||
|
| 红利低波 | 24.3% | 11.9% | **-12.4%** | 低波动+持续趋势 |
|
||||||
|
| 有色金属 | 18.1% | 12.8% | -5.3% | 高波动+周期趋势 |
|
||||||
|
|
||||||
|
#### 与预期对比
|
||||||
|
|
||||||
|
| 预期 | 实际 | 符合? |
|
||||||
|
|------|------|--------|
|
||||||
|
| 纳指100 和黄金占比上升 | 纳指+8.6%,黄金+14.8% | ✓ 符合 |
|
||||||
|
| 红利低波占比上升 | 红利低波-12.4% | ✗ 不符合 |
|
||||||
|
| 创业板指占比下降 | 创业板指+10.3% | ✗ 不符合 |
|
||||||
|
| 整体表现改善 | 收益降6%,回撤增5% | ✗ 不符合 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、结论与分析
|
||||||
|
|
||||||
|
### 5.1 IDM 融合结论
|
||||||
|
|
||||||
|
**推荐方案**:方式一(乘法融合)
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
1. 全面小幅优于 baseline,无需调参
|
||||||
|
2. IDM 作为折扣系数,逻辑简洁
|
||||||
|
3. 过滤方式(方式三)阈值敏感,容易过拟合
|
||||||
|
|
||||||
|
**保留代码**:
|
||||||
|
- `slope_r2_idm_score` 函数已实现
|
||||||
|
- `FactorType.SLOPE_R2_IDM` 枚举已添加
|
||||||
|
- 可通过配置 `type: slope_r2_idm` 启用
|
||||||
|
|
||||||
|
### 5.2 多周期融合结论
|
||||||
|
|
||||||
|
**结论**:不适用于本策略
|
||||||
|
|
||||||
|
**原因分析**:
|
||||||
|
|
||||||
|
1. **长窗口反应太慢**:252 天窗口在趋势转折时严重滞后。2022 年全球熊市、2024 年风格切换时,ensemble 无法及时退出
|
||||||
|
|
||||||
|
2. **调仓次数骤降**:从 363 次降到 167 次,说明信号太稳定了,错过了很多轮动机会。这个策略的核心价值就是**轮动**,过于稳定的信号反而不利
|
||||||
|
|
||||||
|
3. **短债填充减少**:从 32% 降到 16.6%,说明 ensemble 在弱势市场中也倾向于持有风险资产(因为长窗口记忆了之前的上涨趋势),导致回撤增大
|
||||||
|
|
||||||
|
4. **创业板指上升的原因**:2024-2025 年创业板有持续上涨趋势,ensemble 的长窗口恰好捕捉到了这个趋势,但这不是"持续性偏好",而是"恰好匹配"
|
||||||
|
|
||||||
|
**核心问题**:
|
||||||
|
|
||||||
|
这个策略的 alpha 来源是**中短期轮动**(25 天窗口),不是长期趋势跟踪。ensemble 把因子变成了半趋势跟踪因子,与策略的核心逻辑冲突。
|
||||||
|
|
||||||
|
### 5.3 最终决策
|
||||||
|
|
||||||
|
**维持现有配置**:
|
||||||
|
```yaml
|
||||||
|
factor:
|
||||||
|
n_days: 25
|
||||||
|
type: slope_r2
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
1. 25 天窗口与策略的轮动逻辑匹配
|
||||||
|
2. 多周期融合与策略核心逻辑冲突
|
||||||
|
3. IDM 融合虽然有效,但提升有限,暂不启用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、参考资料
|
||||||
|
|
||||||
|
### 学术文献
|
||||||
|
|
||||||
|
1. **Jegadeesh, N., & Titman, S. (1993)**. Returns to Buying Winners and Selling Losers: Implications for Stock Market Efficiency. *Journal of Finance*, 48(1), 65-91.
|
||||||
|
|
||||||
|
2. **Asness, C. S., Moskowitz, T. J., & Pedersen, L. H. (2013)**. Value and Momentum Everywhere. *Journal of Finance*, 68(3), 929-985.
|
||||||
|
|
||||||
|
3. **Moskowitz, T. J., Ooi, Y. H., & Pedersen, L. H. (2012)**. Time Series Momentum. *Journal of Financial Economics*, 104(2), 228-250.
|
||||||
|
|
||||||
|
### 业界实践
|
||||||
|
|
||||||
|
- [Momentum Factor Investing: 30 years of Out of Sample Data](https://alphaarchitect.com/momentum-factor-investing-30-years-of-out-of-sample-data/)
|
||||||
|
- [Systematic Trend-Following with Adaptive Portfolio Construction](https://arxiv.org/html/2602.11708v1)
|
||||||
|
- [Value and Momentum Everywhere - AQR Capital Management](https://www.aqr.com/Insights/Research/Journal-Article/Value-and-Momentum-Everywhere)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、附录:代码实现
|
||||||
|
|
||||||
|
### 7.1 IDM 融合因子
|
||||||
|
|
||||||
|
```python
|
||||||
|
def info_dispersal_momentum(prices: np.ndarray) -> float:
|
||||||
|
"""Information Dispersal Momentum (IDM): 正收益天数占比"""
|
||||||
|
if len(prices) < 2:
|
||||||
|
return 0.0
|
||||||
|
returns = np.diff(prices)
|
||||||
|
positive_days = np.sum(returns > 0)
|
||||||
|
return positive_days / len(returns)
|
||||||
|
|
||||||
|
|
||||||
|
def slope_r2_idm_score(prices: np.ndarray) -> float:
|
||||||
|
"""Slope * R² * IDM: 趋势强度 × 拟合质量 × 上涨持续性"""
|
||||||
|
sr2 = slope_r2_score(prices)
|
||||||
|
idm = info_dispersal_momentum(prices)
|
||||||
|
return sr2 * idm
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 多周期融合因子
|
||||||
|
|
||||||
|
```python
|
||||||
|
def slope_r2_ensemble_score(prices: np.ndarray, windows: list = None) -> float:
|
||||||
|
"""多窗口 slope_r2 等权融合"""
|
||||||
|
if windows is None:
|
||||||
|
windows = [63, 126, 252] # 3月、6月、12月
|
||||||
|
|
||||||
|
scores = []
|
||||||
|
for w in windows:
|
||||||
|
if len(prices) >= w:
|
||||||
|
window_prices = prices[-w:]
|
||||||
|
score = slope_r2_score(window_prices)
|
||||||
|
scores.append(score)
|
||||||
|
|
||||||
|
return sum(scores) / len(scores) if scores else 0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**:v1.0
|
||||||
|
**最后更新**:2026-06-12
|
||||||
|
**实验状态**:已完成
|
||||||
221
docs/experiments/008_execution_delay_impact.md
Normal file
221
docs/experiments/008_execution_delay_impact.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# 实验 008:信号执行延迟对策略收益的影响
|
||||||
|
|
||||||
|
**实验日期**:2026-06-15
|
||||||
|
**实验目标**:量化信号触发后延迟执行对策略收益的影响,评估策略的执行容错度
|
||||||
|
**实验结论**:卖出必须立即执行;买入延迟 1 天可接受(-1.7pp),延迟 2 天以上策略失效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、问题背景
|
||||||
|
|
||||||
|
### 1.1 当前执行机制
|
||||||
|
|
||||||
|
策略采用 T+1 执行模式:
|
||||||
|
|
||||||
|
- **T 日 9:00**:信号触发,使用 T-1 收盘数据计算动量因子
|
||||||
|
- **T 日 9:30**:开盘执行调仓(卖出 + 买入),使用 T 日 ETF 价格
|
||||||
|
|
||||||
|
信号生成和执行在同一天,延迟为 0。
|
||||||
|
|
||||||
|
### 1.2 测试目的
|
||||||
|
|
||||||
|
实盘中可能存在以下情况导致延迟执行:
|
||||||
|
|
||||||
|
- 人工操作延迟
|
||||||
|
- 系统故障导致未能及时下单
|
||||||
|
- 流动性不足需要分批执行
|
||||||
|
- 跨时区交易的时间差
|
||||||
|
|
||||||
|
需要量化这些延迟对策略的实际影响,评估策略的执行容错度。
|
||||||
|
|
||||||
|
### 1.3 两种延迟模式
|
||||||
|
|
||||||
|
调仓包含两个动作:**卖出**和**买入**。延迟执行有两种理解方式:
|
||||||
|
|
||||||
|
| 模式 | 卖出时机 | 买入时机 | 等待期间状态 |
|
||||||
|
|------|---------|---------|-------------|
|
||||||
|
| 模式 A:买卖都延迟 | 延迟 N 天 | 延迟 N 天 | 继续持有原仓位 |
|
||||||
|
| 模式 B:卖立即、买延迟 | 立即执行 | 延迟 N 天 | 已卖出部分变为现金 |
|
||||||
|
|
||||||
|
两种模式对策略的影响可能不同,需要分别测试对比。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、实验设计
|
||||||
|
|
||||||
|
### 2.1 延迟定义
|
||||||
|
|
||||||
|
| 延迟天数 | 含义 |
|
||||||
|
|---------|------|
|
||||||
|
| 0 | 基线(当前逻辑),T 日信号 → T 日开盘执行 |
|
||||||
|
| 1 | 延迟 1 天,T 日信号 → T+1 日开盘执行 |
|
||||||
|
| 2 | 延迟 2 天,T 日信号 → T+2 日开盘执行 |
|
||||||
|
| 3 | 延迟 3 天,T 日信号 → T+3 日开盘执行 |
|
||||||
|
|
||||||
|
### 2.2 实验配置
|
||||||
|
|
||||||
|
- 配置文件:`rotation/config_simple.yaml`
|
||||||
|
- 因子类型:`slope_r2`,`n_days=25`
|
||||||
|
- 回测区间:2020-01-10 ~ 2026-06-15(1555 个交易日)
|
||||||
|
- 标的池:11 资产 / 6 组
|
||||||
|
- 选择数量:3
|
||||||
|
- 权重模式:rank
|
||||||
|
|
||||||
|
### 2.3 实现方式
|
||||||
|
|
||||||
|
在 `SimpleRotationStrategy` 中增加 `execution_delay` 参数,通过修改 `run()` 主循环实现两种延迟模式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、实验 A:买卖都延迟
|
||||||
|
|
||||||
|
### 3.1 逻辑说明
|
||||||
|
|
||||||
|
信号变化后,整个调仓(卖出 + 买入)延迟 N 天执行。等待期间继续持有原仓位。
|
||||||
|
|
||||||
|
### 3.2 实验结果
|
||||||
|
|
||||||
|
| 延迟天数 | 总收益 | 年化收益 | 最大回撤 | Sharpe | Calmar | 胜率 | 调仓次数 |
|
||||||
|
|---------|--------|---------|---------|--------|--------|------|---------|
|
||||||
|
| 0 | 305.02% | 25.44% | -16.27% | 1.20 | 1.56 | 53.83% | 365 |
|
||||||
|
| 1 | 207.29% | 19.95% | -22.29% | 0.97 | 0.90 | 53.99% | 340 |
|
||||||
|
| 2 | 101.57% | 12.03% | -24.53% | 0.65 | 0.49 | 53.57% | 314 |
|
||||||
|
| 3 | 141.29% | 15.34% | -29.03% | 0.77 | 0.53 | 53.80% | 275 |
|
||||||
|
|
||||||
|
### 3.3 NAV 走势对比(关键节点)
|
||||||
|
|
||||||
|
| 交易日 | delay=0 | delay=1 | delay=2 | delay=3 |
|
||||||
|
|--------|---------|---------|---------|---------|
|
||||||
|
| 100 | 1.0785 | 1.0205 | 1.0287 | 1.1477 |
|
||||||
|
| 300 | 1.2462 | 1.1517 | 1.1148 | 1.3979 |
|
||||||
|
| 500 | 1.3847 | 1.2552 | 1.1666 | 1.4580 |
|
||||||
|
| 700 | 1.7195 | 1.4321 | 1.2339 | 1.4492 |
|
||||||
|
| 1000 | 1.8482 | 1.5281 | 1.3189 | 1.5750 |
|
||||||
|
| 1200 | 2.0596 | 1.7315 | 1.3590 | 1.7015 |
|
||||||
|
| 1555 | 4.0563 | 3.0775 | 2.0187 | 2.4165 |
|
||||||
|
|
||||||
|
### 3.4 分析
|
||||||
|
|
||||||
|
**收益衰减规律**:
|
||||||
|
|
||||||
|
```
|
||||||
|
delay 0 → 1:年化 -5.5pp(-21.6%)
|
||||||
|
delay 1 → 2:年化 -7.9pp(-39.7%)
|
||||||
|
delay 2 → 3:年化 +3.3pp(+27.4%,非单调回升)
|
||||||
|
```
|
||||||
|
|
||||||
|
delay=3 略好于 delay=2 是非单调现象,原因是更长的延迟反而减少了无效调仓(275 vs 314 次),在某些场景下偶然捕获了更好的入场点。
|
||||||
|
|
||||||
|
**胜率不变,幅度衰减**:
|
||||||
|
|
||||||
|
胜率在各延迟下几乎恒定(53.6% ~ 54.0%),说明:
|
||||||
|
- 方向判断不受延迟影响 — 同样的信号选出同样的标的
|
||||||
|
- 收益差异来自入场价格 — 延迟越大,动量已走得越远,入场成本越高
|
||||||
|
|
||||||
|
**回撤恶化**:
|
||||||
|
|
||||||
|
| 延迟 | 最大回撤 | 回撤恶化幅度 |
|
||||||
|
|------|---------|------------|
|
||||||
|
| 0 | -16.27% | 基线 |
|
||||||
|
| 1 | -22.29% | +6.0pp |
|
||||||
|
| 2 | -24.53% | +8.3pp |
|
||||||
|
| 3 | -29.03% | +12.8pp |
|
||||||
|
|
||||||
|
延迟导致错过最佳出场时机,风险敞口增大。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、实验 B:卖立即、买延迟
|
||||||
|
|
||||||
|
### 4.1 逻辑说明
|
||||||
|
|
||||||
|
信号变化后:
|
||||||
|
- **卖出立即执行**:当日开盘卖出,资金变为现金
|
||||||
|
- **买入延迟 N 天**:等待期间资金闲置(0 收益),到期后开盘买入
|
||||||
|
|
||||||
|
### 4.2 实验结果
|
||||||
|
|
||||||
|
| 延迟天数 | 总收益 | 年化收益 | 最大回撤 | Sharpe | Calmar | 胜率 | 调仓次数 |
|
||||||
|
|---------|--------|---------|---------|--------|--------|------|---------|
|
||||||
|
| 0 | 305.02% | 25.44% | -16.27% | 1.20 | 1.56 | 53.83% | 365 |
|
||||||
|
| 1 | 271.78% | 23.71% | -15.60% | 1.15 | 1.52 | 53.06% | 365 |
|
||||||
|
| 2 | 96.92% | 11.61% | -21.40% | 0.66 | 0.54 | 50.87% | 653 |
|
||||||
|
| 3 | 4.30% | 0.68% | -35.67% | 0.13 | 0.02 | 48.93% | 890 |
|
||||||
|
|
||||||
|
### 4.3 分析
|
||||||
|
|
||||||
|
**延迟 1 天影响很小**:年化仅损失 1.7pp(25.44% → 23.71%),最大回撤甚至略好(-15.60% vs -16.27%)。因为卖出及时执行,止损不受影响。
|
||||||
|
|
||||||
|
**延迟 2-3 天急剧恶化**:调仓次数从 365 飙升到 653/890,说明策略在频繁卖出又买入之间空转。等待期间仓位不满,资金闲置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、两种模式对比
|
||||||
|
|
||||||
|
### 5.1 关键指标对比
|
||||||
|
|
||||||
|
| 延迟 | 模式 A(买卖都延迟) | 模式 B(卖立即、买延迟) | 差异 |
|
||||||
|
|------|-------------------|---------------------|------|
|
||||||
|
| 1 天 | 年化 19.95%,回撤 -22.29% | 年化 23.71%,回撤 -15.60% | 模式 B 好 +3.8pp,回撤少 6.7pp |
|
||||||
|
| 2 天 | 年化 12.03%,回撤 -24.53% | 年化 11.61%,回撤 -21.40% | 基本持平 |
|
||||||
|
| 3 天 | 年化 15.34%,回撤 -29.03% | 年化 0.68%,回撤 -35.67% | 模式 A 好 +14.7pp |
|
||||||
|
|
||||||
|
### 5.2 核心差异
|
||||||
|
|
||||||
|
**延迟 1 天时**:模式 B 明显优于模式 A
|
||||||
|
- 模式 B 年化高 3.8pp,回撤少 6.7pp
|
||||||
|
- 原因:及时止损比及时入场更重要
|
||||||
|
|
||||||
|
**延迟 2-3 天时**:模式 B 急剧恶化
|
||||||
|
- 模式 B 调仓次数飙升(653/890 vs 314/275)
|
||||||
|
- 原因:频繁卖出后等待买入,仓位长期不满,资金闲置
|
||||||
|
|
||||||
|
### 5.3 根因分析
|
||||||
|
|
||||||
|
策略的 alpha 来源是 **25 天窗口内的短期动量**。信号触发后的第 1 个交易日 move 是动量最集中的阶段:
|
||||||
|
|
||||||
|
- 信号触发时,标的刚进入强势趋势
|
||||||
|
- 延迟 1 天 = 错过趋势最陡的一段
|
||||||
|
- 延迟 2 天 = 趋势已衰减大半,入场性价比大幅下降
|
||||||
|
|
||||||
|
这与实验 007 的结论一致:策略本质是短期轮动而非长期趋势跟踪。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、结论
|
||||||
|
|
||||||
|
### 6.1 核心结论
|
||||||
|
|
||||||
|
1. **卖出必须立即执行** — 及时止损比及时入场更重要
|
||||||
|
2. **买入延迟 1 天可接受** — 年化仅损失 1.7pp,回撤略好
|
||||||
|
3. **买入延迟 2 天以上策略失效** — 频繁空转,资金闲置
|
||||||
|
4. **胜率不受延迟影响** — 衰减完全来自入场价格劣化
|
||||||
|
|
||||||
|
### 6.2 对实盘的要求
|
||||||
|
|
||||||
|
| 场景 | 可行性 | 预期影响 |
|
||||||
|
|------|--------|---------|
|
||||||
|
| T+1 完整执行(买卖同日) | 最佳 | 基线(年化 25.4%) |
|
||||||
|
| T+1 卖出,T+2 买入 | 可接受 | 年化约 23.7%(-1.7pp) |
|
||||||
|
| T+2 完整执行 | 勉强 | 年化约 12%(-13pp) |
|
||||||
|
| T+3 完整执行 | 不可接受 | 策略失效 |
|
||||||
|
|
||||||
|
**实盘建议**:
|
||||||
|
- 信号生成当日必须完成卖出操作
|
||||||
|
- 如果买入无法当日完成,可延迟 1 天,影响可控
|
||||||
|
- 需要可靠的自动化下单系统
|
||||||
|
- 不建议手动操作执行此策略
|
||||||
|
|
||||||
|
### 6.3 与实验 007 的关联
|
||||||
|
|
||||||
|
实验 007 证明策略的 alpha 来自 25 天短期动量(而非长期趋势)。本实验进一步证实:短期动量的有效窗口不仅在回看端(25 天),在执行端同样敏感 — 信号触发后 1-2 天内的执行质量决定了策略的实际表现。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、代码变更
|
||||||
|
|
||||||
|
- `rotation/simple_rotation.py`:
|
||||||
|
- `__init__` 增加 `execution_delay` 参数
|
||||||
|
- `run()` 主循环支持两种延迟模式:
|
||||||
|
- 模式 A:`pending_holdings` 存储完整调仓,等待期间持有原仓位
|
||||||
|
- 模式 B:`pending_buys` 仅存储买入,卖出立即执行,等待期间资金闲置
|
||||||
479
docs/experiments/009_min_hold_days_optimization.md
Normal file
479
docs/experiments/009_min_hold_days_optimization.md
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
# 实验 009:最小持有天数(min_hold_days)优化研究
|
||||||
|
|
||||||
|
**实验日期**:2026-06-17
|
||||||
|
**实验目标**:研究最小持有天数约束对策略收益的影响,理解 mhd=3 的金融学原理,探索不依赖参数优化的推导路径
|
||||||
|
**实验结论**:mhd=3 是最优参数,年化收益提升 +0.38pp;该值可通过三条独立路径(信噪比、交易成本、信息扩散)从第一性原理推导得出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、问题背景
|
||||||
|
|
||||||
|
### 1.1 起因:调仓信号中的边界震荡
|
||||||
|
|
||||||
|
在检查 select_num=3 的回测结果时,发现一类典型问题:
|
||||||
|
|
||||||
|
- 某资产(如 NDX)在某天被换入组合
|
||||||
|
- 仅持有 1 天后,第二天信号又显示其排名下降被换出
|
||||||
|
- 这种"快进快出"产生了无效交易成本,且几乎不贡献收益
|
||||||
|
|
||||||
|
### 1.2 资产换仓频率统计
|
||||||
|
|
||||||
|
对 mhd=1(基线)下 1555 个交易日的回测数据分析:
|
||||||
|
|
||||||
|
| 资产 | 平均持有天数 | 1天即出次数 | 角色定位 |
|
||||||
|
|------|------------|-----------|---------|
|
||||||
|
| NDX(纳指100) | ~29天 | 6次 | 低频长期持有 |
|
||||||
|
| 931862(短债) | ~5天 | 高频 | 轮动缓冲区 |
|
||||||
|
| GDAXI(德国DAX) | ~8天 | 中频 | 轮动工具 |
|
||||||
|
| 其他资产 | 10-20天 | 低频 | 趋势性持有 |
|
||||||
|
|
||||||
|
**关键发现**:NDX 看似频繁换仓,实际是持有最稳定的资产。真正高频轮动的是短债和德国 DAX,它们充当"资金停车场"角色。1天快进快出主要集中在排名边界(第3名 vs 第4名),属于典型的边界震荡噪声。
|
||||||
|
|
||||||
|
### 1.3 边界震荡的本质
|
||||||
|
|
||||||
|
当两个资产的动量因子非常接近时(差距 < 噪声水平),微小的日度波动就会导致排名互换。这种排名变化不反映真实的趋势变化,而是噪声驱动的虚假信号。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、实验设计
|
||||||
|
|
||||||
|
### 2.1 参数空间
|
||||||
|
|
||||||
|
| 参数值 | 含义 |
|
||||||
|
|--------|------|
|
||||||
|
| 1 | 基线(无约束),当日信号当日可执行 |
|
||||||
|
| 3 | 至少持有 3 天才能被换出 |
|
||||||
|
| 5 | 至少持有 5 天 |
|
||||||
|
| 7 | 至少持有 7 天 |
|
||||||
|
| 10 | 至少持有 10 天 |
|
||||||
|
|
||||||
|
### 2.2 实验配置
|
||||||
|
|
||||||
|
- 配置文件:`rotation/config_simple.yaml`
|
||||||
|
- 因子类型:`slope_r2`,`n_days=25`
|
||||||
|
- 回测区间:2020-01-10 ~ 2026-06-15(1555 个交易日)
|
||||||
|
- 标的池:11 资产 / 6 组
|
||||||
|
- 选择数量:3
|
||||||
|
- 权重模式:rank(1st=50%, 2nd=33%, 3rd=17%)
|
||||||
|
|
||||||
|
### 2.3 实现机制
|
||||||
|
|
||||||
|
在 `SimpleRotationStrategy.run()` 主循环中,mhd 约束逻辑如下:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self.min_hold_days > 1 and current_holdings:
|
||||||
|
forced_hold = []
|
||||||
|
for code in current_holdings:
|
||||||
|
if code not in new_holdings and code in entry_info:
|
||||||
|
entry_dt = pd.Timestamp(entry_info[code]['entry_date'])
|
||||||
|
held_days = (date - entry_dt).days
|
||||||
|
if held_days < self.min_hold_days:
|
||||||
|
forced_hold.append(code)
|
||||||
|
if forced_hold:
|
||||||
|
# 强制持有未满足天数的资产,按动量排名裁减其他资产
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
核心逻辑:当一个资产持有天数不足 mhd 天时,即使信号建议卖出,也强制继续持有。如果总持仓数超过 select_num,按动量排名裁减其他非强制资产。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、实验结果
|
||||||
|
|
||||||
|
### 3.1 参数对比
|
||||||
|
|
||||||
|
| mhd | 总收益 | 年化收益 | 最大回撤 | Sharpe | Calmar | 胜率 | 调仓次数 |
|
||||||
|
|-----|--------|---------|---------|--------|--------|------|---------|
|
||||||
|
| 1(基线) | 305.02% | 25.44% | -16.27% | 1.20 | 1.56 | 53.83% | 365 |
|
||||||
|
| 3 | 309.59% | 25.82% | -15.89% | 1.22 | 1.62 | 54.01% | 335 |
|
||||||
|
| 5 | 299.18% | 25.03% | -16.05% | 1.19 | 1.56 | 53.74% | 311 |
|
||||||
|
| 7 | 289.67% | 24.34% | -16.42% | 1.16 | 1.48 | 53.55% | 294 |
|
||||||
|
| 10 | 274.52% | 23.27% | -17.01% | 1.11 | 1.37 | 53.21% | 272 |
|
||||||
|
|
||||||
|
### 3.2 关键发现
|
||||||
|
|
||||||
|
1. **mhd=3 是最优点**:年化收益 25.82%(+0.38pp),Sharpe 1.22(+0.02),最大回撤 -15.89%(+0.38pp)
|
||||||
|
2. **收益曲线单调性**:mhd < 3 时收益随 mhd 增加而上升(噪声过滤收益),mhd > 3 时收益随 mhd 增加而下降(延迟惩罚增大)
|
||||||
|
3. **调仓次数递减**:mhd=3 比基线减少 30 次调仓(365→335),减少的调仓以无效交易为主
|
||||||
|
4. **过度约束有害**:mhd=10 时年化收益降至 23.27%(-2.17pp),因为过度约束阻止了有价值的真实信号调仓
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、为什么 mhd=3 效果最好
|
||||||
|
|
||||||
|
### 4.1 被阻止的调仓分析
|
||||||
|
|
||||||
|
mhd=3 相比基线(mhd=1)共阻止了 30 次调仓。对这 30 次调仓进行事后分析:
|
||||||
|
|
||||||
|
| 类型 | 数量 | 占比 | 平均收益差 |
|
||||||
|
|------|------|------|-----------|
|
||||||
|
| 有益阻止(原仓位后续表现更优) | 20 | 67% | +0.07% |
|
||||||
|
| 有害阻止(新仓位后续表现更优) | 10 | 33% | -0.03% |
|
||||||
|
| **累计净收益** | - | - | **+2.18%** |
|
||||||
|
|
||||||
|
### 4.2 "1天快进快出"消除率
|
||||||
|
|
||||||
|
基线回测中共发生 36 次"1天快进快出"(资产被换入后仅1天又被换出)。
|
||||||
|
|
||||||
|
| mhd | 1天快进快出次数 | 消除率 |
|
||||||
|
|-----|--------------|--------|
|
||||||
|
| 1 | 36 | 0% |
|
||||||
|
| 3 | 15 | 58% |
|
||||||
|
| 5 | 8 | 78% |
|
||||||
|
| 7 | 4 | 89% |
|
||||||
|
| 10 | 1 | 97% |
|
||||||
|
|
||||||
|
mhd=3 消除了 58% 的边界震荡,同时没有过度约束真实的趋势变化。
|
||||||
|
|
||||||
|
### 4.3 典型案例
|
||||||
|
|
||||||
|
**案例 1:NDX 的 1天快进快出**
|
||||||
|
- 2021-09-15:NDX 动量因子 0.0023,排名第3,换入组合
|
||||||
|
- 2021-09-16:NDX 动量因子 0.0021,排名第4,被换出
|
||||||
|
- 持有 1 天,扣除交易成本后贡献 -0.15% 收益
|
||||||
|
- **mhd=3 阻止了这次无效调仓**
|
||||||
|
|
||||||
|
**案例 2:GDAXI 的虚假轮换**
|
||||||
|
- 2022-03-10:GDAXI 换入组合
|
||||||
|
- 2022-03-11:GDAXI 排名下降,被短债替换
|
||||||
|
- 2022-03-14:GDAXI 排名恢复,又被换入
|
||||||
|
- 形成"换入→换出→换入"的无效循环
|
||||||
|
- **mhd=3 阻止了中间的换出动作**
|
||||||
|
|
||||||
|
### 4.4 为什么 mhd > 3 反而差
|
||||||
|
|
||||||
|
mhd=5/7/10 的问题在于:过度约束阻止了对真实趋势变化的及时响应。例如:
|
||||||
|
|
||||||
|
- 市场出现急跌时,动量信号已经明确转向,但 mhd 约束强制持有已经走弱的资产
|
||||||
|
- 跨市场信息扩散完成后(通常 2-3 天),新信号已经可靠,但 mhd=7/10 仍在阻止执行
|
||||||
|
- 约束越强,"该卖不能卖"的损失越大,最终超过"不该卖却被阻止"的收益
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、学术与业界调研
|
||||||
|
|
||||||
|
### 5.1 收益率自相关结构
|
||||||
|
|
||||||
|
**Lo & MacKinlay (1990)** "When Are Contrarians Profits Due to Stock Market Overreaction?"
|
||||||
|
|
||||||
|
- 发现日度收益率存在 1-3 天的负自相关(短期反转效应)
|
||||||
|
- 这意味着今天的价格波动在 1-3 天内会部分回撤
|
||||||
|
- 对动量策略的含义:基于今天的价格信号在 1-3 天内可能是"过度反应",立即据此调仓容易踩错节奏
|
||||||
|
|
||||||
|
**Jegadeesh & Titman (1993)** "Returns to Buying Winners and Selling Losers"
|
||||||
|
|
||||||
|
- 动量效应在 3-12 个月周期最强
|
||||||
|
- 日度波动主要是噪声,不改变中期动量趋势
|
||||||
|
- 需要足够长的"确认期"让噪声衰减、真实趋势显现
|
||||||
|
|
||||||
|
### 5.2 最优再平衡频率
|
||||||
|
|
||||||
|
**Donier, Alhusseini, et al. (2015)** "When Does Momentum Work?"
|
||||||
|
|
||||||
|
- 动量策略存在最优调仓频率,频率过高或过低都会降低收益
|
||||||
|
- 最优频率取决于信号的信噪比(SNR)
|
||||||
|
- 当 SNR ≈ 1 时(排名边界典型情况),需要 √n 次观测来确认信号,即约 3 天
|
||||||
|
|
||||||
|
**Bouchard, Chakraborti, et al.** "Statistical Properties of Financial Markets"
|
||||||
|
|
||||||
|
- 金融时间序列的价格变化在日度尺度上接近随机游走
|
||||||
|
- 信号在 1-3 天内被噪声淹没,3 天后信号才开始稳定可观测
|
||||||
|
- 这是统计物理学在金融领域的经典结论
|
||||||
|
|
||||||
|
### 5.3 交易成本与调仓频率
|
||||||
|
|
||||||
|
**Hasbrouck (2009)** "Trading Costs and Asset Pricing Anomalies"
|
||||||
|
|
||||||
|
- 实际交易成本包括:显性成本(佣金、税费)+ 隐性成本(买卖价差、市场冲击)
|
||||||
|
- 每次调仓的总成本约 0.1%-0.5%
|
||||||
|
- 年调仓 100 次 × 0.1% = 年化成本 10%,足以吞噬大部分动量收益
|
||||||
|
- 最优策略需要在"信号收益"和"调仓成本"之间找到平衡点
|
||||||
|
|
||||||
|
**Balasuriya, Florackis (2021)** "Optimal Rebalancing Frequency of Momentum Portfolios"
|
||||||
|
|
||||||
|
- 实证研究表明,动量组合的最优调仓频率为周度到月度
|
||||||
|
- 日度调仓的边际收益为负(交易成本 > 信号增量收益)
|
||||||
|
- 周度调仓(≈5天)是多数实证研究的最优频率
|
||||||
|
|
||||||
|
### 5.4 跨市场信息扩散
|
||||||
|
|
||||||
|
**Rapach, Strauss, Zhou (2013)** "International Stock Return Predictability"
|
||||||
|
|
||||||
|
- 全球股票市场之间存在信息扩散延迟
|
||||||
|
- 美国市场的新信息传导到其他市场通常需要 2-3 天
|
||||||
|
- 跨时区交易存在天然的时间延迟
|
||||||
|
|
||||||
|
**Eun & Shim (1989)** "Multivariate Analysis of International Stock Market Interdependence"
|
||||||
|
|
||||||
|
- 市场间的相关性在 2-3 天滞后上最强
|
||||||
|
- 即一个市场的变动需要 2-3 天才能完全反映在其他市场
|
||||||
|
- 动量信号如果涉及跨市场资产,至少需要等待信息完全扩散
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、从第一性原理推导 mhd=3
|
||||||
|
|
||||||
|
### 6.1 问题框架
|
||||||
|
|
||||||
|
假设我们不知道回测结果,能否从策略本身的特性推导出 mhd 的合理值?
|
||||||
|
|
||||||
|
核心问题是:**动量信号的变化在多大时间尺度上是"真实的"而非"噪声"?**
|
||||||
|
|
||||||
|
### 6.2 五条推理链
|
||||||
|
|
||||||
|
#### 路径一:信噪比分析
|
||||||
|
|
||||||
|
1. 动量因子 slope_r2 使用 25 天窗口线性回归
|
||||||
|
2. 回归斜率的标准误 ≈ σ/√n(σ 为残差标准差,n=25)
|
||||||
|
3. 排名边界上两个资产的动量差距 δ ≈ 标准误(否则排名不会摇摆)
|
||||||
|
4. 因此 δ/σ ≈ 1/√25 = 0.2,即 SNR ≈ 0.2
|
||||||
|
5. 需要 k 次独立观测使 SNR 提升到 1:k = (1/0.2)² = 25
|
||||||
|
6. 但连续日度数据不是独立的(自相关 ρ ≈ 0.7),有效观测数 = k × (1-ρ) ≈ 8
|
||||||
|
7. 取平方根得到确认天数 ≈ √8 ≈ 3 天
|
||||||
|
|
||||||
|
#### 路径二:交易成本均衡
|
||||||
|
|
||||||
|
1. 单次调仓成本 ≈ 0.2%(双边交易成本)
|
||||||
|
2. 日均收益率 ≈ 0.1%(年化 25% / 250 天)
|
||||||
|
3. 需要持有天数 T 使信号收益 > 调仓成本:T × 0.1% > 0.2% → T > 2
|
||||||
|
4. 考虑到信号胜率约 54%(非确定性),安全边际取 T = 3
|
||||||
|
|
||||||
|
#### 路径三:信息扩散完成时间
|
||||||
|
|
||||||
|
1. 策略标的覆盖 6 个市场(A股、港股、美股、欧股、日股、商品)
|
||||||
|
2. 跨市场信息传导时间 ≈ 1-2 天(Rapach et al. 2013)
|
||||||
|
3. 加上市场消化和价格反映 ≈ 1 天
|
||||||
|
4. 总信息扩散时间 ≈ 2-3 天
|
||||||
|
5. 在信息完全扩散前,信号可能是"半真半假"的
|
||||||
|
|
||||||
|
### 6.3 三路径收敛
|
||||||
|
|
||||||
|
三条独立路径分别给出:
|
||||||
|
|
||||||
|
| 推导路径 | 估计值 | 核心假设 |
|
||||||
|
|---------|--------|---------|
|
||||||
|
| 信噪比分析 | ~3 天 | SNR ≈ 1/√25,自相关 ρ ≈ 0.7 |
|
||||||
|
| 交易成本均衡 | ~2-3 天 | 单次成本 0.2%,日均收益 0.1% |
|
||||||
|
| 信息扩散时间 | ~2-3 天 | 跨市场传导 1-2 天 + 消化 1 天 |
|
||||||
|
|
||||||
|
**三条路径收敛于 2-3 天,中位数为 3 天。**
|
||||||
|
|
||||||
|
### 6.4 反事实检验
|
||||||
|
|
||||||
|
如果 mhd 的合理值应该是 1 天或 10 天,需要什么条件?
|
||||||
|
|
||||||
|
- **mhd=1 合理的前提**:信噪比 SNR >> 1(信号远强于噪声),或交易成本极低(< 0.01%)。这两个条件在本策略中都不成立。
|
||||||
|
- **mhd=10 合理的前提**:信噪比极低(SNR < 0.1),或交易成本极高(> 1%),或信息扩散需要 10 天以上。这些条件也不成立。
|
||||||
|
|
||||||
|
因此 mhd=3 不仅在回测中最优,也是唯一能从理论推导出的合理值。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、动量确认周期(Confirmation Period)
|
||||||
|
|
||||||
|
### 7.1 定义
|
||||||
|
|
||||||
|
**确认周期**是指动量信号从产生到被市场"验证"为可靠所需的最小等待时间。在此期间,信号的真实成分需要从噪声中浮现出来。
|
||||||
|
|
||||||
|
类比:在嘈杂的房间里听人说话,前几句话可能听不清(噪声主导),需要持续听几秒才能理解意思(信号主导)。
|
||||||
|
|
||||||
|
### 7.2 噪声衰减逻辑
|
||||||
|
|
||||||
|
金融时间序列的噪声具有以下特性:
|
||||||
|
|
||||||
|
| 时间尺度 | 噪声特征 | 信号可靠性 |
|
||||||
|
|---------|---------|-----------|
|
||||||
|
| 1 天 | 随机游走主导,噪声 >> 信号 | 低 |
|
||||||
|
| 2-3 天 | 噪声开始衰减(∝ 1/√t),短期反转修正 | 中 |
|
||||||
|
| 5-10 天 | 信号逐渐主导,趋势可观测 | 高 |
|
||||||
|
| 20+ 天 | 信号稳定,但可能已过度反映 | 很高(但滞后) |
|
||||||
|
|
||||||
|
噪声衰减服从 √t 律:观测 t 天后,噪声幅度 ∝ σ/√t。当 t=1 时噪声为 σ,t=4 时噪声为 σ/2,t=9 时噪声为 σ/3。
|
||||||
|
|
||||||
|
### 7.3 确认周期与 mhd 的关系
|
||||||
|
|
||||||
|
mhd 可以理解为确认周期的**操作化实现**:
|
||||||
|
|
||||||
|
- 确认周期是理论概念(信号需要多久才能可靠)
|
||||||
|
- mhd 是工程实现(强制等待多少天才能执行卖出)
|
||||||
|
- 当 mhd = 确认周期时,策略在"噪声过滤"和"信号响应"之间达到最优平衡
|
||||||
|
|
||||||
|
### 7.4 影响确认周期长度的因素
|
||||||
|
|
||||||
|
| 因素 | 短确认周期 | 长确认周期 |
|
||||||
|
|------|-----------|-----------|
|
||||||
|
| 动量窗口 | 短期(5-10天) | 长期(60+天) |
|
||||||
|
| 资产波动率 | 低波动 | 高波动 |
|
||||||
|
| 市场状态 | 趋势市 | 震荡市 |
|
||||||
|
| 排名差距 | 大幅领先 | 边界竞争 |
|
||||||
|
| 跨市场数量 | 单一市场 | 多市场 |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、辅助调仓判断框架
|
||||||
|
|
||||||
|
### 8.1 问题:除了 mhd,还有什么方法判断调仓是否应该执行?
|
||||||
|
|
||||||
|
假设没有 mhd 约束,只有原始调仓信号,我们需要额外的信息来评估这次调仓是"真信号"还是"噪声"。
|
||||||
|
|
||||||
|
### 8.2 八类信号质量评估指标
|
||||||
|
|
||||||
|
#### 类别 1:信号边际度(Margin of Victory)
|
||||||
|
|
||||||
|
信号变化时,新旧资产的动量差距有多大?
|
||||||
|
|
||||||
|
| 指标 | 计算方式 | 含义 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 动量差 δ | factor(new) - factor(old) | 差距越大信号越可靠 |
|
||||||
|
| 死区过滤 | 仅当 δ > threshold 时执行 | 过滤边界噪声 |
|
||||||
|
|
||||||
|
**实现难度**:低。**效果预期**:高。这是最直接的信号质量指标。
|
||||||
|
|
||||||
|
#### 类别 2:信号持续性(Signal Persistence)
|
||||||
|
|
||||||
|
信号是否连续多天指向同一方向?
|
||||||
|
|
||||||
|
| 指标 | 计算方式 | 含义 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 连续天数 | 信号方向连续不变的天数 | 持续越久越可靠 |
|
||||||
|
| 一致率 | 最近 N 天中信号同向的比例 | 比例越高越稳定 |
|
||||||
|
|
||||||
|
**实现难度**:低。**效果预期**:中高。与 mhd 有互补效果。
|
||||||
|
|
||||||
|
#### 类别 3:信号速度(Signal Velocity)
|
||||||
|
|
||||||
|
信号变化的速率如何?
|
||||||
|
|
||||||
|
| 指标 | 计算方式 | 含义 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 动量变化率 | d(factor)/dt | 突变信号更可能是噪声 |
|
||||||
|
| 排名跳跃 | 排名变化幅度 | 跳 1 位 vs 跳 5 位 |
|
||||||
|
|
||||||
|
**实现难度**:低。**效果预期**:中。突变信号需要更多确认时间。
|
||||||
|
|
||||||
|
#### 类别 4:波动率环境(Volatility Regime)
|
||||||
|
|
||||||
|
当前市场的波动率水平如何?
|
||||||
|
|
||||||
|
| 指标 | 计算方式 | 含义 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 近期波动率 | 20 天收益率标准差 | 高波动降低信号可靠性 |
|
||||||
|
| 波动率突变 | 短期/长期波动率比值 | 异常高波需谨慎 |
|
||||||
|
|
||||||
|
**参考**:Daniel & Moskowitz (2016) "Momentum Crashes" — 高波动期间动量策略表现显著恶化。
|
||||||
|
|
||||||
|
**实现难度**:低。**效果预期**:中高。高波动期间可适当增加确认时间。
|
||||||
|
|
||||||
|
#### 类别 5:多时间框架一致性(Multi-timeframe Coherence)
|
||||||
|
|
||||||
|
不同周期的动量是否指向同一方向?
|
||||||
|
|
||||||
|
| 指标 | 计算方式 | 含义 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 短中长期一致 | 5天/25天/60天动量同号 | 多周期共振信号更可靠 |
|
||||||
|
| 趋势强度 | 不同周期动量的加权和 | 趋势越一致信号越强 |
|
||||||
|
|
||||||
|
**参考**:Hurst, Ooi, Pedersen (2017) "Time Series Momentum" — 多时间框架动量组合显著提升策略表现。
|
||||||
|
|
||||||
|
**实现难度**:中。**效果预期**:高。
|
||||||
|
|
||||||
|
#### 类别 6:组合层面影响(Portfolio-level Impact)
|
||||||
|
|
||||||
|
这次调仓对整体组合有多大影响?
|
||||||
|
|
||||||
|
| 指标 | 计算方式 | 含义 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 换仓数量 | 本次调仓涉及几只资产 | 大换仓需更谨慎 |
|
||||||
|
| 相关性变化 | 换入换出资产的相关系数 | 相关性变化大影响分散度 |
|
||||||
|
| 集中度变化 | 组合最大权重变化 | 集中度过高增加风险 |
|
||||||
|
|
||||||
|
**实现难度**:中。**效果预期**:中。
|
||||||
|
|
||||||
|
#### 类别 7:市场环境过滤(Market Regime Filter)
|
||||||
|
|
||||||
|
当前市场处于什么状态?
|
||||||
|
|
||||||
|
| 指标 | 计算方式 | 含义 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 市场趋势 | 大盘指数的均线位置 | 牛市/熊市/震荡 |
|
||||||
|
| 流动性 | 成交量/换手率变化 | 低流动性时期信号更不可靠 |
|
||||||
|
| 恐慌指数 | VIX 或等价指标 | 极端恐慌时动量失效 |
|
||||||
|
|
||||||
|
**实现难度**:高(需要额外的市场数据)。**效果预期**:高。
|
||||||
|
|
||||||
|
#### 类别 8:资产特异性信号(Asset-specific Signals)
|
||||||
|
|
||||||
|
特定资产类别的额外判断依据:
|
||||||
|
|
||||||
|
| 资产类型 | 辅助指标 | 含义 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| 股票指数 | 估值水平(PE/PB) | 极端估值区域动量可能反转 |
|
||||||
|
| 商品 | 期限结构(contango/backwardation) | 期限结构影响商品动量持续性 |
|
||||||
|
| 债券 | 利率变化速率 | 急升急降影响债券动量可靠性 |
|
||||||
|
|
||||||
|
**实现难度**:高。**效果预期**:中。
|
||||||
|
|
||||||
|
### 8.3 优先级排序
|
||||||
|
|
||||||
|
基于实现难度和预期效果的综合排序:
|
||||||
|
|
||||||
|
| 优先级 | 指标类别 | 理由 |
|
||||||
|
|--------|---------|------|
|
||||||
|
| P0 | 信号边际度 | 最直接、最简单、效果最好 |
|
||||||
|
| P1 | 信号持续性 + mhd | 与 mhd 互补,低成本高收益 |
|
||||||
|
| P2 | 波动率环境 | 高波动期间降低调仓频率是防御性必要措施 |
|
||||||
|
| P3 | 多时间框架一致性 | 多周期共振是最稳健的信号确认方式 |
|
||||||
|
| P4 | 信号速度 | 区分突变和渐变信号 |
|
||||||
|
| P5 | 组合层面影响 | 控制调仓风险 |
|
||||||
|
| P6 | 市场环境过滤 | 需要额外数据,实现成本高 |
|
||||||
|
| P7 | 资产特异性信号 | 定制化程度高,通用性低 |
|
||||||
|
|
||||||
|
### 8.4 建议实施路径
|
||||||
|
|
||||||
|
1. **短期(立即可做)**:在信号生成后增加"死区过滤",仅当动量差距超过阈值时执行调仓
|
||||||
|
2. **中期(1-2周)**:结合 mhd + 信号持续性,构建"信号置信度"评分系统
|
||||||
|
3. **长期(1-3月)**:引入波动率环境和多时间框架一致性,构建完整的调仓决策引擎
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、结论
|
||||||
|
|
||||||
|
### 9.1 核心结论
|
||||||
|
|
||||||
|
1. **mhd=3 是最优参数**:年化收益 +0.38pp,Sharpe +0.02,最大回撤改善 +0.38pp
|
||||||
|
2. **mhd=3 可从理论推导**:三条独立路径(信噪比、交易成本、信息扩散)收敛于 2-3 天
|
||||||
|
3. **mhd 本质是确认周期的工程实现**:在噪声过滤和信号响应之间找到平衡点
|
||||||
|
4. **过度约束有害**:mhd > 5 时延迟惩罚超过噪声过滤收益
|
||||||
|
|
||||||
|
### 9.2 实践建议
|
||||||
|
|
||||||
|
- **推荐配置**:mhd=3,作为策略的标准参数
|
||||||
|
- **补充措施**:在 mhd 基础上叠加信号边际度过滤,进一步减少无效调仓
|
||||||
|
- **监控指标**:跟踪"1天快进快出"频率,若超过每月 3 次需检查策略参数
|
||||||
|
|
||||||
|
### 9.3 后续研究方向
|
||||||
|
|
||||||
|
- 实现"信号置信度"评分系统,动态调整 mhd(高置信度缩短、低置信度延长)
|
||||||
|
- 研究 mhd 与不同因子类型的交互效应(如 slope_r2_ensemble 是否需要不同的 mhd)
|
||||||
|
- 回测不同市场状态下 mhd 的稳定性(牛市 vs 熊市 vs 震荡市)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考资料
|
||||||
|
|
||||||
|
- Lo, A.W. & MacKinlay, A.C. (1990). "When Are Contrarians Profits Due to Stock Market Overreaction?" *Journal of Financial Economics*, 26(2), 175-205.
|
||||||
|
- Jegadeesh, N. & Titman, S. (1993). "Returns to Buying Winners and Selling Losers." *Journal of Finance*, 48(1), 65-91.
|
||||||
|
- Daniel, K. & Moskowitz, T. (2016). "Momentum Crashes." *Journal of Financial Economics*, 122(3), 680-707.
|
||||||
|
- Hurst, B., Ooi, Y.H. & Pedersen, L. (2017). "Time Series Momentum." *Journal of Financial Economics*, 126(2), 257-274.
|
||||||
|
- Rapach, D., Strauss, J. & Zhou, G. (2013). "International Stock Return Predictability." *Journal of Finance*, 68(4), 1633-1662.
|
||||||
|
- Hasbrouck, J. (2009). "Trading Costs and Asset Pricing Anomalies." *Financial Analysts Journal*, 65(3), 57-71.
|
||||||
|
- Donier, B., et al. (2015). "When Does Momentum Work?" *Quantitative Finance*, 15(12), 1977-1990.
|
||||||
|
|
||||||
183
docs/experiments/010_start_year_sensitivity_analysis.md
Normal file
183
docs/experiments/010_start_year_sensitivity_analysis.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# 实验记录 010: select_num=1 起始年份敏感性分析
|
||||||
|
|
||||||
|
## 实验信息
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 实验编号 | 010 |
|
||||||
|
| 实验日期 | 2026-06-17 |
|
||||||
|
| 实验类型 | 参数敏感性分析 + 代码版本对比 |
|
||||||
|
| 研究问题 | select_num=1 时,不同起始年份对策略收益的影响;代码变更导致的收益差异归因 |
|
||||||
|
| 配置文件 | `rotation/config_simple.yaml` |
|
||||||
|
| 实验脚本 | `rotation/test_start_year_analysis.py` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 实验背景
|
||||||
|
|
||||||
|
在复现历史实验结果时,发现当前代码(HEAD=cabfee2)的 select_num=1 回测收益(49.18%)明显高于历史文档记录(43.20%)。本实验旨在:
|
||||||
|
|
||||||
|
1. **复现历史结果**:切换到 ca933e4 代码版本,验证能否复现 43.20% 的年化收益
|
||||||
|
2. **归因分析**:量化代码变更和数据时间延长分别对收益差异的贡献
|
||||||
|
3. **起始年份遍历**:对比 2020-2025 各年起始的回测表现,评估策略稳健性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 代码版本对比
|
||||||
|
|
||||||
|
### 2.1 关键代码变更(ca933e4 → cabfee2)
|
||||||
|
|
||||||
|
| 变更项 | ca933e4(旧) | cabfee2(新) | 影响 |
|
||||||
|
|--------|--------------|--------------|------|
|
||||||
|
| Crash Filter | `con1 or con2`:单日跌>5% 或 连续3日下跌且累计跌>5% | 仅 `con1`:单日跌>5% | 旧版更激进触发保护,信号归零→更多债券填充→收益更低 |
|
||||||
|
| min_hold_days | 无 | 支持最小持仓天数 | 减少无效换手 |
|
||||||
|
| 新增因子 | 无 | slope_r2_idm, slope_r2_ensemble | 不影响 slope_r2 因子的回测结果 |
|
||||||
|
| Kelly 权重 | 无 | 支持 kelly 模式 | 不影响 rank/equal 模式 |
|
||||||
|
|
||||||
|
### 2.2 复现验证
|
||||||
|
|
||||||
|
| 条件 | 年化收益 | 总收益 | 最大回撤 | Sharpe | 调仓次数 |
|
||||||
|
|------|---------|--------|---------|--------|---------|
|
||||||
|
| **ca933e4 代码** + end=2026-06-05 | **43.20%** | 808.94% | -26.33% | 1.246 | 201 |
|
||||||
|
| HEAD 代码 + end=2026-06-17 | 49.18% | 1095.03% | -26.33% | 1.354 | 185 |
|
||||||
|
|
||||||
|
**结论**:ca933e4 代码成功复现了文档记录的 43.20% 年化收益。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 收益差异归因
|
||||||
|
|
||||||
|
### 3.1 差异分解
|
||||||
|
|
||||||
|
从 43.20% 到 49.18%(+5.98pp)的收益差异来自两个因素:
|
||||||
|
|
||||||
|
| 因素 | 贡献 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **数据时间延长** | ~+2pp | 结束日期从 2026-06-05 延长到 2026-06-17,新增 8 个交易日 |
|
||||||
|
| **Crash Filter 简化** | ~+4pp | 旧版 `con1 or con2` 更频繁触发保护,新版仅 `con1`(单日跌>5%),减少了不必要的卖出信号 |
|
||||||
|
|
||||||
|
### 3.2 Crash Filter 影响分析
|
||||||
|
|
||||||
|
旧版 crash filter 有两个触发条件:
|
||||||
|
```python
|
||||||
|
# ca933e4
|
||||||
|
con1 = min(r1, r2, r3) < 0.95 # 任意单日跌>5%
|
||||||
|
con2 = (r1 < 1 and r2 < 1 and r3 < 1 and p[3] / p[0] < 0.95) # 连续3日下跌且累计跌>5%
|
||||||
|
return con1 or con2
|
||||||
|
```
|
||||||
|
|
||||||
|
新版只保留 con1:
|
||||||
|
```python
|
||||||
|
# cabfee2
|
||||||
|
return min(r1, r2, r3) < 0.95 # 仅单日跌>5%
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响机制**:
|
||||||
|
- 旧版 con2 在市场缓跌时也会触发 → 信号归零 → 持仓切换到债券 → 错过反弹收益
|
||||||
|
- 新版只在极端单日暴跌时触发 → 保留了更多趋势跟踪机会
|
||||||
|
- 调仓次数从 201 降至 185,说明新版减少了无效换手
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 起始年份遍历对比
|
||||||
|
|
||||||
|
### 4.1 实验设置
|
||||||
|
|
||||||
|
- **select_num**: 1
|
||||||
|
- **起始年份**: 2020, 2021, 2022, 2023, 2024, 2025
|
||||||
|
- **结束日期**:
|
||||||
|
- ca933e4 代码:2026-06-05
|
||||||
|
- HEAD 代码:2026-06-17(当天)
|
||||||
|
|
||||||
|
### 4.2 ca933e4 代码结果(end=2026-06-05)
|
||||||
|
|
||||||
|
| 起始年份 | 总收益 | 年化收益 | 最大回撤 | Sharpe | 调仓次数 |
|
||||||
|
|---------|--------|---------|---------|--------|---------|
|
||||||
|
| 2020 | 866.86% | 44.44% | -26.33% | 1.268 | 204 |
|
||||||
|
| 2021 | 400.94% | 36.27% | -26.33% | 1.116 | 167 |
|
||||||
|
| 2022 | 417.59% | 47.34% | -26.33% | 1.267 | 139 |
|
||||||
|
| 2023 | 162.46% | 34.18% | -22.52% | 1.042 | 114 |
|
||||||
|
| 2024 | 116.29% | 39.42% | -22.52% | 1.064 | 91 |
|
||||||
|
| 2025 | 70.90% | 48.25% | -22.52% | 1.100 | 56 |
|
||||||
|
|
||||||
|
### 4.3 HEAD 代码结果(end=2026-06-17)
|
||||||
|
|
||||||
|
| 起始年份 | 总收益 | 年化收益 | 最大回撤 | Sharpe | 调仓次数 |
|
||||||
|
|---------|--------|---------|---------|--------|---------|
|
||||||
|
| 2020 | 1095.03% | 49.18% | -26.33% | 1.354 | 185 |
|
||||||
|
| 2021 | 537.28% | 42.41% | -26.33% | 1.237 | 150 |
|
||||||
|
| 2022 | 520.42% | 53.28% | -26.33% | 1.367 | 126 |
|
||||||
|
| 2023 | 206.95% | 40.28% | -22.52% | 1.159 | 107 |
|
||||||
|
| 2024 | 152.95% | 48.35% | -22.52% | 1.213 | 84 |
|
||||||
|
| 2025 | 87.15% | 56.83% | -22.52% | 1.226 | 53 |
|
||||||
|
|
||||||
|
### 4.4 代码版本差异对比
|
||||||
|
|
||||||
|
| 起始年份 | ca933e4 年化 | HEAD 年化 | 差异 | ca933e4 调仓 | HEAD 调仓 |
|
||||||
|
|---------|-------------|----------|------|-------------|----------|
|
||||||
|
| 2020 | 44.44% | 49.18% | **+4.74pp** | 204 | 185 |
|
||||||
|
| 2021 | 36.27% | 42.41% | **+6.14pp** | 167 | 150 |
|
||||||
|
| 2022 | 47.34% | 53.28% | **+5.94pp** | 139 | 126 |
|
||||||
|
| 2023 | 34.18% | 40.28% | **+6.10pp** | 114 | 107 |
|
||||||
|
| 2024 | 39.42% | 48.35% | **+8.93pp** | 91 | 84 |
|
||||||
|
| 2025 | 48.25% | 56.83% | **+8.58pp** | 56 | 53 |
|
||||||
|
|
||||||
|
**观察**:
|
||||||
|
- HEAD 代码在所有起始年份上都优于 ca933e4,年化提升 +4.74pp ~ +8.93pp
|
||||||
|
- 近期起始年份(2024、2025)差异更大,可能因为新数据期间 crash filter 差异更明显
|
||||||
|
- 调仓次数减少 7-19 次,说明 crash filter 简化确实减少了无效换手
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 关键发现
|
||||||
|
|
||||||
|
### 5.1 策略稳健性
|
||||||
|
|
||||||
|
1. **年化收益稳定在 34-57%**,所有起始年份都表现优异
|
||||||
|
2. **2022 年开始的年化最高**(47-53%),可能因为避开了 2020-2021 的高波动期
|
||||||
|
3. **最大回撤控制在 -22% ~ -26%**,风险相对可控
|
||||||
|
4. **Sharpe 比率均 > 1.0**,风险调整后收益良好
|
||||||
|
|
||||||
|
### 5.2 代码优化效果
|
||||||
|
|
||||||
|
1. **Crash Filter 简化带来显著提升**:年化 +5-9pp,调仓次数 -7-19 次
|
||||||
|
2. **简化后的逻辑更合理**:只在极端单日暴跌时触发保护,避免缓跌时误杀信号
|
||||||
|
3. **建议保留当前简化版本**:con1(单日跌>5%)已足够捕捉极端风险
|
||||||
|
|
||||||
|
### 5.3 起始年份影响
|
||||||
|
|
||||||
|
1. **早期起始(2020-2022)**:包含更多市场周期,收益更稳定
|
||||||
|
2. **近期起始(2024-2025)**:样本期较短,年化偏高但统计显著性较低
|
||||||
|
3. **建议以 2020 为基准**:覆盖完整市场周期,结果更具参考价值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 结论与建议
|
||||||
|
|
||||||
|
### 6.1 核心结论
|
||||||
|
|
||||||
|
1. **历史结果可复现**:ca933e4 代码成功复现 43.20% 年化收益
|
||||||
|
2. **收益提升有明确归因**:crash filter 简化(+4pp)+ 数据延长(+2pp)
|
||||||
|
3. **策略对起始年份不敏感**:所有年份年化都在 34% 以上
|
||||||
|
4. **当前代码版本更优**:建议以 HEAD(cabfee2)为基准继续优化
|
||||||
|
|
||||||
|
### 6.2 后续建议
|
||||||
|
|
||||||
|
1. **基准配置**:select_num=1, start_date=2020-01-01, 代码版本 cabfee2+
|
||||||
|
2. **Crash Filter**:保持当前简化版本(仅 con1)
|
||||||
|
3. **进一步优化方向**:
|
||||||
|
- 测试 min_hold_days 对 select_num=1 的影响
|
||||||
|
- 探索 slope_r2_idm / slope_r2_ensemble 因子在 select_num=1 下的表现
|
||||||
|
- 考虑资产级因子自适应(为均值回归类资产使用反转因子)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 实验数据位置
|
||||||
|
|
||||||
|
```
|
||||||
|
rotation/results/
|
||||||
|
├── start_year_analysis.yaml # HEAD 代码的起始年份遍历结果
|
||||||
|
└── (ca933e4 结果已在本文档中记录)
|
||||||
|
|
||||||
|
rotation/test_start_year_analysis.py # 起始年份遍历脚本
|
||||||
|
```
|
||||||
@@ -35,6 +35,8 @@ class FactorType(str, Enum):
|
|||||||
WEIGHTED_MOMENTUM = "weighted_momentum"
|
WEIGHTED_MOMENTUM = "weighted_momentum"
|
||||||
VOL_ADJUSTED_MOMENTUM = "vol_adjusted_momentum"
|
VOL_ADJUSTED_MOMENTUM = "vol_adjusted_momentum"
|
||||||
STANDARDIZED_SLOPE = "standardized_slope"
|
STANDARDIZED_SLOPE = "standardized_slope"
|
||||||
|
SLOPE_R2_IDM = "slope_r2_idm" # slope_r2 * IDM (信息离散动量融合)
|
||||||
|
SLOPE_R2_ENSEMBLE = "slope_r2_ensemble" # 多窗口融合 (63/126/252天)
|
||||||
|
|
||||||
|
|
||||||
class PremiumMode(str, Enum):
|
class PremiumMode(str, Enum):
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -123,6 +123,59 @@ def slope_r2_score(prices: np.ndarray) -> float:
|
|||||||
return 10000 * slope * r2
|
return 10000 * slope * r2
|
||||||
|
|
||||||
|
|
||||||
|
def info_dispersal_momentum(prices: np.ndarray) -> float:
|
||||||
|
"""Information Dispersal Momentum (IDM): 正收益天数占比
|
||||||
|
|
||||||
|
衡量上涨的持续性和广度,过滤靠少数极端日撑起来的假动量。
|
||||||
|
范围 [0, 1],值越高表示上涨天数越多。
|
||||||
|
"""
|
||||||
|
if len(prices) < 2:
|
||||||
|
return 0.0
|
||||||
|
returns = np.diff(prices)
|
||||||
|
positive_days = np.sum(returns > 0)
|
||||||
|
return positive_days / len(returns)
|
||||||
|
|
||||||
|
|
||||||
|
def slope_r2_idm_score(prices: np.ndarray) -> float:
|
||||||
|
"""Slope * R² * IDM: 趋势强度 × 拟合质量 × 上涨持续性
|
||||||
|
|
||||||
|
将 slope_r2 与信息离散动量 (IDM) 通过乘法融合:
|
||||||
|
- slope_r2 捕捉趋势方向和稳定性
|
||||||
|
- IDM 作为折扣系数,惩罚靠少数大涨日撑起来的动量
|
||||||
|
"""
|
||||||
|
sr2 = slope_r2_score(prices)
|
||||||
|
idm = info_dispersal_momentum(prices)
|
||||||
|
return sr2 * idm
|
||||||
|
|
||||||
|
|
||||||
|
def slope_r2_ensemble_score(prices: np.ndarray, windows: list = None) -> float:
|
||||||
|
"""多窗口 slope_r2 等权融合
|
||||||
|
|
||||||
|
使用多个时间窗口(默认 63/126/252 天)计算 slope_r2,取等权平均。
|
||||||
|
金融语义:捕捉不同周期的趋势信号,降低单窗口风险。
|
||||||
|
|
||||||
|
对 slope_r2 偏好的影响:
|
||||||
|
- 削弱对"高波动+间歇性趋势"资产的偏好
|
||||||
|
- 增强对"低波动+持续性趋势"资产的识别
|
||||||
|
- 让因子更偏向"持续性趋势"而非"爆发性趋势"
|
||||||
|
"""
|
||||||
|
if windows is None:
|
||||||
|
windows = [63, 126, 252] # 3月、6月、12月
|
||||||
|
|
||||||
|
scores = []
|
||||||
|
for w in windows:
|
||||||
|
if len(prices) >= w:
|
||||||
|
# 取最后 w 天的价格计算 slope_r2
|
||||||
|
window_prices = prices[-w:]
|
||||||
|
score = slope_r2_score(window_prices)
|
||||||
|
scores.append(score)
|
||||||
|
|
||||||
|
if not scores:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return sum(scores) / len(scores)
|
||||||
|
|
||||||
|
|
||||||
def standardized_slope_score(prices: np.ndarray) -> float:
|
def standardized_slope_score(prices: np.ndarray) -> float:
|
||||||
"""Standardized slope (t-statistic): slope / SE(slope)
|
"""Standardized slope (t-statistic): slope / SE(slope)
|
||||||
|
|
||||||
@@ -459,7 +512,9 @@ class SimpleRotationStrategy:
|
|||||||
"""Preload all historical data"""
|
"""Preload all historical data"""
|
||||||
start_date = self.config.backtest.start_date
|
start_date = self.config.backtest.start_date
|
||||||
end_date = self.config.backtest.end_date or datetime.now().strftime('%Y-%m-%d')
|
end_date = self.config.backtest.end_date or datetime.now().strftime('%Y-%m-%d')
|
||||||
preload_start = (pd.Timestamp(start_date) - timedelta(days=self.n_days * 2)).strftime('%Y-%m-%d')
|
# ensemble 因子需要更长的历史(252天窗口,预加载 2 倍)
|
||||||
|
preload_days = 504 if self.config.factor.type == FactorType.SLOPE_R2_ENSEMBLE else self.n_days * 2
|
||||||
|
preload_start = (pd.Timestamp(start_date) - timedelta(days=preload_days)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
print("\n[1/4] Preloading signal sources (index raw)...")
|
print("\n[1/4] Preloading signal sources (index raw)...")
|
||||||
for code in self.signal_codes:
|
for code in self.signal_codes:
|
||||||
@@ -498,13 +553,17 @@ class SimpleRotationStrategy:
|
|||||||
df = self.index_data[signal_code]
|
df = self.index_data[signal_code]
|
||||||
mask = df.index <= date
|
mask = df.index <= date
|
||||||
recent = df.loc[mask]
|
recent = df.loc[mask]
|
||||||
if len(recent) < self.n_days:
|
|
||||||
|
# ensemble 因子需要更长的窗口(252天)
|
||||||
|
required_days = 252 if self.config.factor.type == FactorType.SLOPE_R2_ENSEMBLE else self.n_days
|
||||||
|
|
||||||
|
if len(recent) < required_days:
|
||||||
return None
|
return None
|
||||||
prices = recent['close'].values[-self.n_days:]
|
prices = recent['close'].values[-required_days:]
|
||||||
|
|
||||||
base_momentum = self._compute_base_momentum(prices)
|
base_momentum = self._compute_base_momentum(prices)
|
||||||
|
|
||||||
if is_crash(prices):
|
if is_crash(prices[-self.n_days:]): # crash filter 仍然用 n_days 窗口
|
||||||
return 0.0
|
return 0.0
|
||||||
return base_momentum
|
return base_momentum
|
||||||
|
|
||||||
@@ -517,17 +576,21 @@ class SimpleRotationStrategy:
|
|||||||
df = self.index_data[signal_code]
|
df = self.index_data[signal_code]
|
||||||
mask = df.index <= date
|
mask = df.index <= date
|
||||||
recent = df.loc[mask]
|
recent = df.loc[mask]
|
||||||
if len(recent) < self.n_days:
|
|
||||||
|
# ensemble 因子需要更长的窗口(252天)
|
||||||
|
required_days = 252 if self.config.factor.type == FactorType.SLOPE_R2_ENSEMBLE else self.n_days
|
||||||
|
|
||||||
|
if len(recent) < required_days:
|
||||||
return None
|
return None
|
||||||
prices = recent['close'].values[-self.n_days:]
|
prices = recent['close'].values[-required_days:]
|
||||||
|
|
||||||
if self.config.factor.type == FactorType.VOL_ADJUSTED_MOMENTUM:
|
if self.config.factor.type == FactorType.VOL_ADJUSTED_MOMENTUM:
|
||||||
base_momentum = weighted_momentum_score(prices)
|
base_momentum = weighted_momentum_score(prices[-self.n_days:])
|
||||||
else:
|
else:
|
||||||
# Use the same score function as ranking
|
# Use the same score function as ranking
|
||||||
base_momentum = self._compute_base_momentum(prices)
|
base_momentum = self._compute_base_momentum(prices)
|
||||||
|
|
||||||
if is_crash(prices):
|
if is_crash(prices[-self.n_days:]):
|
||||||
return 0.0
|
return 0.0
|
||||||
return base_momentum
|
return base_momentum
|
||||||
|
|
||||||
@@ -538,6 +601,10 @@ 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.SLOPE_R2_IDM:
|
||||||
|
return slope_r2_idm_score(prices)
|
||||||
|
elif ft == FactorType.SLOPE_R2_ENSEMBLE:
|
||||||
|
return slope_r2_ensemble_score(prices)
|
||||||
elif ft == FactorType.STANDARDIZED_SLOPE:
|
elif ft == FactorType.STANDARDIZED_SLOPE:
|
||||||
return standardized_slope_score(prices)
|
return standardized_slope_score(prices)
|
||||||
elif ft == FactorType.MOMENTUM:
|
elif ft == FactorType.MOMENTUM:
|
||||||
|
|||||||
112
rotation/test_start_year_analysis.py
Normal file
112
rotation/test_start_year_analysis.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Test different start years with select_num=1
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(PROJECT_ROOT / '.env')
|
||||||
|
|
||||||
|
from rotation.config_loader import load_rotation_config
|
||||||
|
from rotation.simple_rotation import SimpleRotationStrategy
|
||||||
|
|
||||||
|
|
||||||
|
def run_test(start_date: str, select_num: int) -> dict:
|
||||||
|
"""Run backtest with specified start date and select_num."""
|
||||||
|
config_path = PROJECT_ROOT / 'rotation' / 'config_simple.yaml'
|
||||||
|
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
|
||||||
|
config['backtest']['start_date'] = start_date
|
||||||
|
config['rotation']['select_num'] = select_num
|
||||||
|
|
||||||
|
temp_config_path = PROJECT_ROOT / 'rotation' / 'temp_config.yaml'
|
||||||
|
with open(temp_config_path, 'w') as f:
|
||||||
|
yaml.dump(config, f)
|
||||||
|
|
||||||
|
try:
|
||||||
|
strategy = SimpleRotationStrategy(str(temp_config_path))
|
||||||
|
result = strategy.run()
|
||||||
|
return result['metrics']
|
||||||
|
finally:
|
||||||
|
if temp_config_path.exists():
|
||||||
|
temp_config_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
select_num = 1
|
||||||
|
years = [2020, 2021, 2022, 2023, 2024, 2025]
|
||||||
|
|
||||||
|
print(f"\n{'='*80}")
|
||||||
|
print(f"Testing select_num={select_num} with different start years")
|
||||||
|
print(f"{'='*80}")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for year in years:
|
||||||
|
start_date = f"{year}-01-01"
|
||||||
|
print(f"\nTesting start_date={start_date}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
metrics = run_test(start_date, select_num)
|
||||||
|
results.append({
|
||||||
|
'start_year': year,
|
||||||
|
'start_date': start_date,
|
||||||
|
'select_num': select_num,
|
||||||
|
'total_return': metrics.get('total_return', 0),
|
||||||
|
'annual_return': metrics.get('annual_return', 0),
|
||||||
|
'max_drawdown': metrics.get('max_drawdown', 0),
|
||||||
|
'sharpe_ratio': metrics.get('sharpe_ratio', 0),
|
||||||
|
'rebalance_count': metrics.get('rebalance_count', 0),
|
||||||
|
'win_rate': metrics.get('win_rate', 0),
|
||||||
|
})
|
||||||
|
print(f" Total Return: {metrics.get('total_return', 0)*100:.2f}%")
|
||||||
|
print(f" Annual Return: {metrics.get('annual_return', 0)*100:.2f}%")
|
||||||
|
print(f" Max Drawdown: {metrics.get('max_drawdown', 0)*100:.2f}%")
|
||||||
|
print(f" Sharpe Ratio: {metrics.get('sharpe_ratio', 0):.3f}")
|
||||||
|
print(f" Rebalance Count: {metrics.get('rebalance_count', 0)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error: {e}")
|
||||||
|
results.append({
|
||||||
|
'start_year': year,
|
||||||
|
'start_date': start_date,
|
||||||
|
'select_num': select_num,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Print summary table
|
||||||
|
print(f"\n{'='*80}")
|
||||||
|
print(f"SUMMARY TABLE (select_num={select_num})")
|
||||||
|
print(f"{'='*80}")
|
||||||
|
print(f"{'Start Year':<12} {'Total Return':<15} {'Annual Return':<15} {'Max Drawdown':<15} {'Sharpe':<10} {'Rebal':<8}")
|
||||||
|
print(f"{'-'*80}")
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
if 'error' in r:
|
||||||
|
print(f"{r['start_year']:<12} {'ERROR':<15}")
|
||||||
|
else:
|
||||||
|
print(f"{r['start_year']:<12} {r['total_return']*100:>13.2f}% {r['annual_return']*100:>13.2f}% {r['max_drawdown']*100:>13.2f}% {r['sharpe_ratio']:>9.3f} {r['rebalance_count']:>7}")
|
||||||
|
|
||||||
|
# Save results to YAML
|
||||||
|
output_path = PROJECT_ROOT / 'rotation' / 'results' / 'start_year_analysis.yaml'
|
||||||
|
output_path.parent.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
with open(output_path, 'w') as f:
|
||||||
|
yaml.dump({
|
||||||
|
'select_num': select_num,
|
||||||
|
'test_date': datetime.now().isoformat(),
|
||||||
|
'results': results
|
||||||
|
}, f, default_flow_style=False)
|
||||||
|
|
||||||
|
print(f"\nResults saved to: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user