# 982fbe2 后代码变更总结

**基准 commit**: `982fbe2` — fix: 修复跨市场收益率计算Bug  
**变更时间**: 2026-05-22 ~ 2026-05-23  
**状态**: 未提交（working tree 修改）

---

## 变更文件总览

| 文件 | 操作 | 改动量 | 类别 |
|------|------|--------|------|
| `datasource/tushare_source.py` | 修改 | +99 行 | 数据层新增方法 |
| `strategies/rotation/strategy.py` | 修改 | +25/-15 行 | 核心Bug修复 |
| `strategies/rotation/config.yaml` | 修改 | +8/-10 行 | 标的配置修正 |
| `strategies/shared/factors/momentum.py` | 修改 | +5/-1 行 | 因子鲁棒性 |
| `scripts/export_backtest_detail.py` | 新增 | ~480 行 | 回测数据导出工具 |
| `results/backtest_viewer.html` | 新增 | ~650 行 | HTML回放器 |

---

## 一、datasource/tushare_source.py

### 新增方法 1: `fetch_etf_adj()`

获取 ETF 后复权价格数据。通过 `fund_daily` + `fund_adj` 手动计算后复权价格，消除份额折算（拆分）对收益率计算的影响。

**关键实现**：
- `fund_adj` 单次限 2000 条，按 5 年分段请求再拼接
- 输出 `close_hfq = close × adj_factor`
- 包含 `open`, `close`, `adj_factor`, `close_hfq` 等列

### 新增方法 2: `fetch_trade_cal()`

获取 A 股（上交所 SSE）官方交易日历。

```python
def fetch_trade_cal(self, start_date: str, end_date: str) -> pd.DatetimeIndex:
    pro.trade_cal(exchange='SSE', start_date=..., end_date=..., is_open='1')
```

**修复的问题**：之前策略使用 `index_close.index`（联合日历）包含海外独有交易日和周日，每年多约 70 天，导致 CAGR 被年数摊薄、回撤虚增。详见 `联合日历Bug详解.md`。

---

## 二、strategies/rotation/strategy.py

### 修复 1: 使用 SSE 官方交易日历

**问题**：`compute_factors()` 使用 `index_close.index`（所有标的日期的并集），包含非 A 股交易日。

**修复**：
- `_get_data_from_flask_api()` 和 `_get_data_from_local()` 中调用 `fetch_trade_cal()` 获取 SSE 日历，写入 `data['a_share_dates']`
- `compute_factors()` 优先使用 `data['a_share_dates']` 而非 `index_close.index`
- `run_backtest()` 中用 A 股日历对齐 signals：`signals.reindex(a_share_dates, method='ffill')`

```python
# Before
a_share_dates = index_close.index  # 联合日历（含海外交易日）

# After
a_share_dates = data.get('a_share_dates')  # SSE 官方日历
```

### 修复 2: 信号对齐到 A 股日历

`run_backtest()` 中增加信号 reindex 逻辑，确保回测只在 A 股交易日执行：

```python
if a_share_dates is not None:
    signals = signals.reindex(a_share_dates, method='ffill').dropna(subset=[signals.columns[0]])
```

---

## 三、strategies/rotation/config.yaml

### 修改: 标的替换

| 变更 | 原配置 | 新配置 | 原因 |
|------|--------|--------|------|
| 有色金属信号源 | `HG=F`（COMEX 铜期货） | `399395.SZ`（国证有色金属指数） | HG=F 与 159980.SZ 日相关性仅 0.38，属于标的错配；399395.SZ 相关性 0.56 |
| 原油 | `CL=F` + `160723.SZ` | 删除 | CL=F 与 160723.SZ 相关性仅 0.12，160723 是 LOF 有 QDII 额度限制 |

**影响**：标的从 12 只缩减为 10 只，消除了两对信号-执行严重错配的标的。

---

## 四、strategies/shared/factors/momentum.py

### 修复: 加权动量计算鲁棒性

```python
# 新增：价格下界 clip，防止 log(0) 或 log(负数)
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
```

**修复的问题**：某些历史数据中出现 0 值或极端异常值，`np.log(0)` 产生 `-inf` 导致后续回归计算崩溃。

---

## 五、scripts/export_backtest_detail.py（新增）

模式 B（指数信号 + ETF 收益）的逐日回测明细导出脚本。

**输出**：`results/backtest_detail.json`（~5.2 MB，1542 天 × 10 标的）

**每日每标的记录字段**：

| 字段 | 说明 |
|------|------|
| `index_close` | 指数收盘价 |
| `momentum` | 加权动量得分 |
| `rank` | 动量排名（BOND 不参与） |
| `threshold` | V3 动态阈值 |
| `above_threshold` | 是否过阈值 |
| `etf_close` / `etf_open` | ETF 原始价格 |
| `etf_close_hfq` | ETF 后复权收盘价 |
| `premium` | ETF 溢价率 |
| `etf_return_ctc` | ETF close-to-close 日收益率 |
| `index_return` | 指数日收益率 |
| `is_held` | 是否在持仓 |
| `entry_date` / `entry_price_etf` / `entry_price_idx` | 入场信息 |
| `holding_days` | 持有天数 |
| `cum_return_etf` / `cum_return_idx` | 累计收益（ETF/指数） |

**关键实现**：
- 因子计算：先在原始日历计算，再 ffill 对齐到 A 股日历（与 strategy.py 一致）
- 溢价率：通过 `fetch_etf_nav()` 获取基金净值，`(ETF收盘价 - NAV) / NAV`
- NAV 去重：`nav_raw[~nav_raw.index.duplicated(keep='last')]`
- 净值扩展：信号前的日期填充 nav=1.0

---

## 六、results/backtest_viewer.html（新增）

单文件 HTML 回放器，用于人工逐日验证回测数据准确性。

**功能**：
- 文件选择器加载 JSON
- Canvas 净值曲线（点击/拖拽定位日期）
- 策略统计面板（CAGR、最大回撤、夏普、Calmar、胜率、换仓次数、平均持仓天数）
- 全标的排名表（按动量排序，含市场分组、溢价率、动态阈值分隔行）
- 持仓卡片（ETF + 指数双累计收益）
- 调仓明细栏
- 键盘快捷键：← → 翻页，Space 播放/暂停
- 跳转前/后调仓日按钮

---

## 修复前后指标对比

**回测区间**：2020-01-02 ~ 2026-05-19，使用 `own_strategy_etf_vs_index.py` 四模式对比

| 指标 | 修复前 Mode A | 修复后 Mode A | 变化 |
|------|-------------|-------------|------|
| CAGR | 15.63% | 11.80% | -3.83% |
| 最大回撤 | — | -29.49% | — |
| 夏普 | — | 0.818 | — |

| 指标 | 修复前 Mode B | 修复后 Mode B | 变化 |
|------|-------------|-------------|------|
| CAGR | 26.13% | 28.07% | +1.94% |
| 最大回撤 | — | -13.34% | — |
| 夏普 | — | 1.685 | — |

| 模式 | CAGR | 最大回撤 | 夏普 | Calmar |
|------|------|----------|------|--------|
| A (指数→指数) | 11.80% | -29.49% | 0.818 | 0.400 |
| B (指数→ETF) | 28.07% | -13.34% | 1.685 | 2.104 |
| C (ETF→ETF) | 21.27% | -13.27% | 1.304 | 1.603 |
| D (ETF修正) | 17.93% | -13.36% | 1.130 | 1.342 |

**差异分解**：
- 收益差异 B-A: +16.27%/年（ETF 收益 vs 指数收益）
- 信号差异 C-B: -6.80%/年（ETF 信号 vs 指数信号）
- 隔夜偏差 C-D: +3.34%/年（close 买入虚高 vs open 买入现实）

---

## 依赖关系

```
config.yaml (标的配置)
    ↓
tushare_source.py (fetch_trade_cal / fetch_etf_adj)
    ↓
strategy.py (compute_factors → generate_signals → run_backtest)
    ↓
export_backtest_detail.py (导出 JSON)
    ↓
backtest_viewer.html (加载 JSON 可视化)
```
