---
phase: 11-predictv3
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - application/admin/model/History.php
autonomous: true
requirements:
  - PRED-02
  - PRED-05
must_haves:
  truths:
    - "用户可以在回测结果中看到 NDCG@5 指标"
    - "用户可以在回测结果中看到 MRR 指标"
    - "用户可以看到各排名位置的命中分布统计"
    - "系统在数据不足时返回合理的默认值或提示"
  artifacts:
    - path: "application/admin/model/History.php"
      provides: "NDCG、MRR、命中分布计算方法"
      contains: "_calculateNDCG|_calculateMRR|_calculateHitDistribution"
  key_links:
    - from: "_runBacktestV3"
      to: "_calculateNDCG, _calculateMRR, _calculateHitDistribution"
      via: "method call in return statement"
---

# Phase 11 - Plan 01: 回测指标扩展

## Objective

扩展 `_runBacktestV3` 方法的回测指标，新增 NDCG@5、MRR、命中率分布等排名质量评估指标，提升算法评估能力。

**Purpose:** 当前回测仅返回命中率(Top5)和平均排名，缺少排名质量评估指标。NDCG、MRR 是成熟的推荐系统评估指标，能更全面反映预测排名质量。

**Output:** `History.php` 中新增 3 个计算方法，`_runBacktestV3` 返回结果扩展。

## Tasks

### Task 1: 实现 NDCG@5 计算（含空预测保护和公式文档）

<read_first>
- D:\code\php\amlhc\application\admin\model\History.php (line 3495-3560, _runBacktestV3 方法)
</read_first>

<action>
在 `History.php` 文件末尾（类内）新增 `_calculateNDCG` 方法：

```php
/**
 * 计算 NDCG@K (Normalized Discounted Cumulative Gain)
 * 
 * 公式说明:
 * - DCG (Discounted Cumulative Gain) = Σ(rel_i / log2(rank_i + 1))
 *   其中 rel_i = 1 (命中) 或 0 (未命中)，rank_i 为预测排名位置
 * - IDCG (Ideal DCG) = Σ(1 / log2(i + 1)) for i = 1..min(hits, K)
 *   即理想情况下所有命中的号码都排在最前面的DCG值
 * - NDCG = DCG / IDCG，范围 0-1，越接近1表示排名质量越好
 * 
 * @param array $backtestDetails 回测详情数组，每项包含 {hit: bool, rank: int}
 * @param int $K Top-K 参数，默认5，评估前K个预测位置的排名质量
 * @return float NDCG值 (0-1范围)，空数据时返回0
 */
private function _calculateNDCG($backtestDetails, $K = 5)
{
    // 边缘情况处理：空预测或无效参数
    if (empty($backtestDetails) || $K <= 0) {
        return 0;
    }
    
    $dcg = 0;
    $idcg = 0;
    
    // 计算 DCG: 命中号码的排名折损累积值
    foreach ($backtestDetails as $detail) {
        if (!isset($detail['hit']) || !isset($detail['rank'])) {
            continue; // 跳过无效数据
        }
        if ($detail['hit'] && $detail['rank'] > 0 && $detail['rank'] <= $K) {
            // DCG公式: rel / log2(rank + 1)，命中时 rel=1
            $dcg += 1 / log($detail['rank'] + 1, 2);
        }
    }
    
    // 计算 IDCG: 最理想情况下所有命中的 DCG（假设都排在第1位）
    $hitCount = 0;
    foreach ($backtestDetails as $detail) {
        if (isset($detail['hit']) && $detail['hit']) {
            $hitCount++;
        }
    }
    
    for ($i = 1; $i <= min($hitCount, $K); $i++) {
        $idcg += 1 / log($i + 1, 2);
    }
    
    // 返回标准化值，IDCG为0时返回0避免除零错误
    return $idcg > 0 ? round($dcg / $idcg, 4) : 0;
}
```

实现要点：
- 公式：DCG = Σ(1/log2(rank+1))，IDCG = Σ(1/log2(i+1)) for i=1..hits
- 添加空预测保护：检查 $backtestDetails 是否为空
- 添加数据完整性检查：确保 hit 和 rank 字段存在
- 使用 log(rank + 1, 2) 作为折损函数，排名越靠前权重越高
- 返回 0-1 范围的标准化值，越接近 1 表示排名质量越好
</action>

<acceptance_criteria>
- grep 正则匹配: `_calculateNDCG\s*\(` 在 History.php 中存在
- grep 匹配: `empty($backtestDetails)` 在方法中存在（空预测保护）
- 方法返回 float 类型值
- 包含函数级注释说明 NDCG 计算逻辑和公式
</acceptance_criteria>

### Task 2: 实现 MRR 和命中分布计算（含边缘情况处理）

<read_first>
- D:\code\php\amlhc\application\admin\model\History.php (新增的 _calculateNDCG 方法位置)
</read_first>

<action>
在 `_calculateNDCG` 方法后继续新增 `_calculateMRR` 和 `_calculateHitDistribution` 方法：

```php
/**
 * 计算 MRR (Mean Reciprocal Rank)
 * 平均倒数排名，关注命中号码的具体排名位置
 * 
 * 公式说明:
 * - MRR = Σ(1/rank_i) / N，其中 rank_i 为命中号码的排名，N 为测试总数
 * - 未命中的测试项贡献 0 到倒数排名
 * - MRR 范围 0-1，越接近1表示命中号码平均排名越靠前
 * 
 * @param array $backtestDetails 回测详情数组，每项包含 {hit: bool, rank: int}
 * @return float MRR值 (0-1范围)，空数据时返回0
 */
private function _calculateMRR($backtestDetails)
{
    // 边缘情况处理：空预测
    if (empty($backtestDetails)) {
        return 0;
    }
    
    $reciprocalRanks = [];
    
    foreach ($backtestDetails as $detail) {
        if (!isset($detail['hit']) || !isset($detail['rank'])) {
            continue; // 跳过无效数据
        }
        if ($detail['hit'] && $detail['rank'] > 0) {
            $reciprocalRanks[] = 1 / $detail['rank'];
        } else {
            $reciprocalRanks[] = 0; // 未命中记为0
        }
    }
    
    return count($reciprocalRanks) > 0 
        ? round(array_sum($reciprocalRanks) / count($reciprocalRanks), 4) 
        : 0;
}

/**
 * 计算命中率分布
 * 统计各排名位置(1-5)的命中次数分布
 * 
 * 结构定义:
 * - 返回格式: {rank_1: n, rank_2: n, rank_3: n, rank_4: n, rank_5: n}
 * - rank_N 表示预测排名第N位的命中次数
 * - 用于前端柱状图可视化展示
 * 
 * @param array $backtestDetails 回测详情数组，每项包含 {hit: bool, rank: int}
 * @return array 各排名(1-5)的命中次数统计，键名为 rank_1 到 rank_5
 */
private function _calculateHitDistribution($backtestDetails)
{
    // 边缘情况处理：空预测返回全0分布
    if (empty($backtestDetails)) {
        return [
            'rank_1' => 0,
            'rank_2' => 0,
            'rank_3' => 0,
            'rank_4' => 0,
            'rank_5' => 0
        ];
    }
    
    // 初始化分布数组，键名使用 rank_N 格式便于前端解析
    $distribution = [
        'rank_1' => 0,
        'rank_2' => 0,
        'rank_3' => 0,
        'rank_4' => 0,
        'rank_5' => 0
    ];
    
    foreach ($backtestDetails as $detail) {
        if (!isset($detail['hit']) || !isset($detail['rank'])) {
            continue; // 跳过无效数据
        }
        if ($detail['hit'] && $detail['rank'] >= 1 && $detail['rank'] <= 5) {
            $key = 'rank_' . $detail['rank'];
            $distribution[$key]++;
        }
    }
    
    return $distribution;
}
```

实现要点：
- MRR: 命中号码排名倒数平均值，公式 Σ(1/rank)/N
- 命中分布: 明确结构为 `{rank_1: n, rank_2: n, ..., rank_5: n}`
- 两个方法均添加空预测保护和无效数据跳过逻辑
- hit_distribution 使用 rank_N 键名格式，便于前端柱状图渲染
</action>

<acceptance_criteria>
- grep 正则匹配: `_calculateMRR\s*\(` 在 History.php 中存在
- grep 正则匹配: `_calculateHitDistribution\s*\(` 在 History.php 中存在
- grep 匹配: `empty($backtestDetails)` 在两个方法中均存在（空预测保护）
- grep 匹配: `rank_1|rank_2|rank_3|rank_4|rank_5` 在 _calculateHitDistribution 中存在
- 两个方法均包含函数级注释
</acceptance_criteria>

### Task 3: 扩展 _runBacktestV3 返回结果（含数据量检查）

<read_first>
- D:\code\php\amlhc\application\admin\model\History.php (line 3549-3556, _runBacktestV3 返回语句)
</read_first>

<action>
修改 `_runBacktestV3` 方法的返回语句，在原有返回结构中添加新指标和数据量验证：

找到以下代码段（约 line 3549-3556）：
```php
return [
    'hit_rate' => $hitRate,
    'avg_rank' => $avgRank,
    'total_tests' => $testCount,
    'total_hits' => $hits,
    'details' => $details
];
```

替换为：
```php
// 计算新增指标（添加数据量检查）
$minDataThreshold = 50; // 置信度计算最小数据量阈值

// 如果测试数据不足，返回默认值并添加警告
if ($testCount < $minDataThreshold) {
    $ndcg5 = 0;
    $mrr = 0;
    $hitDistribution = [
        'rank_1' => 0,
        'rank_2' => 0,
        'rank_3' => 0,
        'rank_4' => 0,
        'rank_5' => 0
    ];
    $dataWarning = '回测数据不足(' . $testCount . '期)，建议至少50期以获得可靠指标';
} else {
    $ndcg5 = $this->_calculateNDCG($details, 5);
    $mrr = $this->_calculateMRR($details);
    $hitDistribution = $this->_calculateHitDistribution($details);
    $dataWarning = null;
}

$precision5 = $testCount > 0 ? round($hits / ($testCount * 5) * 100, 2) : 0;

return [
    'hit_rate' => $hitRate,
    'avg_rank' => $avgRank,
    'total_tests' => $testCount,
    'total_hits' => $hits,
    'details' => $details,
    // 新增排名质量指标
    'ndcg_5' => $ndcg5,
    'mrr' => $mrr,
    'hit_distribution' => $hitDistribution,
    'precision_5' => $precision5,
    // 数据量警告（不足时提示）
    'data_warning' => $dataWarning,
    'data_sufficient' => $testCount >= $minDataThreshold
];
```

注意：
- 新增指标计算放在 return 语句之前，确保 $details 数组已完整构建
- 添加最小数据量检查（50期），不足时返回默认值和警告提示
- 新增 data_warning 和 data_sufficient 字段供前端展示
</action>

<acceptance_criteria>
- grep 匹配: `ndcg_5` 在 _runBacktestV3 返回结构中存在
- grep 匹配: `mrr` 在 _runBacktestV3 返回结构中存在
- grep 匹配: `hit_distribution` 在 _runBacktestV3 返回结构中存在
- grep 匹配: `precision_5` 在 _runBacktestV3 返回结构中存在
- grep 匹配: `data_warning` 在 _runBacktestV3 返回结构中存在
- grep 匹配: `minDataThreshold` 变量在方法中存在
</acceptance_criteria>

## Verification

执行预测接口验证新指标返回：

```bash
curl -s "http://127.0.0.1:8000/admin/history/predictV3?periods=200&backtest=10" | grep -E "ndcg_5|mrr|hit_distribution|precision_5|data_warning"
```

预期结果：返回 JSON 中包含 ndcg_5、mrr、hit_distribution、precision_5、data_warning 字段。

## Success Criteria

1. `_calculateNDCG`、`_calculateMRR`、`_calculateHitDistribution` 三个方法已实现
2. 所有计算方法包含空预测保护和数据完整性检查
3. NDCG 公式在注释中完整说明：DCG = Σ(1/log2(rank+1))
4. hit_distribution 结构明确为 `{rank_1..rank_5: counts}` 格式
5. `_runBacktestV3` 返回结构包含 ndcg_5、mrr、hit_distribution、precision_5、data_warning 字段
6. 添加数据量检查，不足50期时返回警告
7. 所有新增方法包含函数级注释

## Output

完成后创建 `.planning/phases/11-predictv3/11-01-SUMMARY.md`