## 模糊去重（多因子评分 — P1 阶段）

- **DDS-Section**: 7.3 模糊去重（多因子评分 — P1 阶段）
- **DDS-Lines**: L755-L818

### Extract

#### 多因子评分模型

| 因子 | 分值 | 评分说明 |
|------|------|----------|
| 时间在 ±5 分钟内 | 30 | 时间差越小得分越高，超出窗口直接 0 分 |
| 金额精确一致 | 30 | 金额一致得满分，差额在手续费容差内得部分分(20分) |
| 交易方向一致 | 10 | 方向相同得满分 |
| 订单号相同/相近 | 15 | 完全一致 15 分，包含关系 10 分 |
| 对手方相似 | 10 | Levenshtein 相似度 + contains 判定 |
| 来源关联规则命中 | 5 | 预配置的平台关联规则 |

**总分 = 100 分**

#### 判定阈值（可配置）

| 分值范围 | 判定结果 | 处理方式 |
|----------|----------|----------|
| ≥ 85 | 自动判定重复 | 自动标记 DUPLICATE |
| 60 ~ 84 | 疑似重复 | 标记 PENDING_REVIEW，进入人工确认队列 |
| < 60 | 不判定重复 | 保留独立交易 |

#### 评分算法骨架

```go
type FuzzyScorer struct {
    TimeWindow    time.Duration  // 默认 5 分钟
    AmountEpsilon float64        // 金额容差（手续费）
}

func (s *FuzzyScorer) Score(a, b *Transaction) int {
    score := 0

    // 时间因子 (30分) — 线性衰减
    timeDiff := math.Abs(a.TradeTime.Sub(b.TradeTime).Minutes())
    if timeDiff <= s.TimeWindow.Minutes() {
        score += int(30 * (1 - timeDiff/s.TimeWindow.Minutes()))
    }

    // 金额因子 (30分)
    if a.Amount.Equal(b.Amount) {
        score += 30
    } else if a.Amount.Sub(b.Amount).Abs().LessThan(decimal.NewFromFloat(s.AmountEpsilon)) {
        score += 20
    }

    // 方向因子 (10分)
    if a.Direction == b.Direction { score += 10 }

    // 订单号因子 (15分)
    if a.OrderID != "" && a.OrderID == b.OrderID {
        score += 15
    } else if strings.Contains(a.OrderID, b.OrderID) || strings.Contains(b.OrderID, a.OrderID) {
        score += 10
    }

    // 对手方因子 (10分) — Levenshtein 相似度
    score += int(10 * counterpartySimilarity(a.Counterparty, b.Counterparty))

    // 来源规则因子 (5分)
    if s.platformLinked(a.SourcePlatform, b.SourcePlatform) { score += 5 }

    return score
}
```

#### 配置项

| 配置键 | 默认值 | 说明 |
|--------|--------|------|
| `fuzzy_time_window` | 5 | 模糊匹配时间窗口（分钟） |
| `fuzzy_threshold_high` | 85 | 自动判定重复阈值 |
| `fuzzy_threshold_low` | 60 | 疑似重复阈值 |
| `amount_epsilon` | 0.01 | 金额容差（手续费） |

#### 性能优化

- 按 `trade_time` 时间分桶，避免全表 O(N²) 扫描
- 使用索引 `idx_txn_trade_time` 加速时间范围查询
