新增文档:互换分红损益字段语义与重复计算分析

分析互换分红/付息损益核算链路中的字段语义混乱与分红重复计算问题,
用真实交易数据佐证,供团队对齐字段语义、确定修复方案:

- 核心结论:MarkClosePnl含分红(价差+费+DividendIn),而DividendIn又
  单独存同一笔分红,导致汇总时分红算2次(确定,非2~3次)
- 字段三层分类:客观源头/一次计算/入库字段,理清哪些是录入哪些是计算
- totalInterest链路:payment_interest→求和→×0.01→增值税调整→DividendIn
- MarkClosePnl含分红的四场景对照(手动互换/平仓、自动互换/平仓)
- 真实数据佐证:6笔交易(含EventType=3/4)显示差值=0且价差=0
- 重复次数确定2次:两个层级各自独立计算,非叠加3次
- 演进过程(f9d8a256→2140a97f,用hash+日期)
- 修复方向A(治本:MarkClosePnl不含分红)/B(保守:改汇总公式)
- 附录A:验证SQL(重复检验/守恒检验)
- 附录B:精确文件:类:行号索引
- 附录C:calcFloatClosePnl两处不一致技术债(floatRatio/longRatio)
- 附录D:测试策略(记录golden source分支refactor-swap-event-testable)
This commit is contained in:
hjhan
2026-06-26 07:33:36 +08:00
parent 09f256d3c3
commit 9e6af84e47
@@ -0,0 +1,355 @@
# 互换分红损益:字段语义与重复计算分析
> 本文档分析互换(收益互换)"分红/付息损益"核算链路中存在的字段语义混乱与分红重复计算问题。
> 用真实交易数据佐证,供团队对齐字段语义、确定修复方案。
> 成文于 2026-06,排查范围覆盖 `f9d8a256`(山证基线)至 `1c9b28ff`(最新)。
---
## 一、问题概述
互换的"已实现盈亏"在多个代码路径下**会把同一笔分红计算恰好 2 次**,导致已实现盈亏虚高(分红翻倍)。
根本原因:**`MarkClosePnl`(盯市盈亏)字段把"价差盈亏"和"分红"揉在了一起**,而分红又有独立的字段 `DividendIn`,导致同一笔分红在两个字段里各存了一份,汇总时被重复累加。
> 经过逐行确认代码路径 + 真实数据验证,**确定**是 2 次(非 2~3 次)。证明见第三、四章。
---
## 二、字段分类:客观源头 vs 派生计算
理解整块的关键是分清"客观源头"(不可改的事实)和"派生计算"(基于源头算出来的)。分三层:
### 第一层:客观源头(事实,所有计算的基石)
| 字段 | 所在表 | 来源 | 含义 | 示例值 |
|------|--------|------|------|--------|
| `payment_interest` | `bond_payment` | 外部付息公告 | 每张面值付息额(如每100元付3元)| 3 |
| `PosiQuantity` | `swap_position` | 用户录入/中债估值 | 持仓数量 | 1000万 |
| `PosiGrossPrice` | `swap_position` | 用户录入/中债估值 | 期初全价(相对价) | 1.00 |
| `TradingAmountAvg` | `swap_flow_event` | 用户互换/平仓时输入 | 期末全价(相对价) | 1.00 |
> 这些是"真实发生的事",不随计算方式改变。
### `totalInterest` 的来源(payment_interest → totalInterest → DividendIn 链路)
`totalInterest` **不是** `payment_interest` 本身,而是经过换算的"每张分红率(小数)"。链路:
```
payment_interest (bond_payment, 每张付息额, 如 3)
│ GetBondPaymentService 在区间内求和
Σ payment_interest (如 3+3+3=9)
× 0.01 (从"每100元"转成"小数比率")
0.09
│ ÷ (1+税) × (1-税) (增值税调整, 与 EOD 口径一致)
totalInterest ≈ 0.077 (每张面值的分红率, 小数)
│ 前端 getDivindIn: PosiQuantity × totalInterest × 方向
DividendIn = 1000万 × 0.077 × 方向 = -770000 (本次互换的分红金额)
```
代码位置:后端 `BondPaymentController.GetBondPayMentInterest``BondPaymentController.cs:91-100`),前端 `getDivindIn``incomeSwapTrade.js:197` / `unwindSwapTrade.js:286`)。
### 第二层:一次计算(基于源头直接算)
| 字段/中间值 | 所在表 | 算法 | 示例 |
|------------|--------|------|------|
| `DividendIn` | `swap_flow_event` | `PosiQuantity × totalInterest × 方向`(后端算每张率,前端算金额)| -9148.25 |
| 价差盈亏(临时值) | 无独立字段 | `CloseNotionalValue × (期末价 - 期初价) × 方向` | 0(期末=期初时)|
| `TradingFee` | `swap_flow_event` | 用户填的费用 | 0 |
### 第三层:入库字段(⚠️ 问题所在)
| 字段 | 所在表 | 算法 | ⚠️ 问题 |
|------|--------|------|---------|
| **`MarkClosePnl`** | `swap_flow_event` | `价差盈亏 + 交易费 + DividendIn` | **把分红揉进来了!** |
| `DividendIn` | `swap_flow_event` | 直接存第二层的值 | 和上面 MarkClosePnl 里的分红是**同一笔** |
**核心矛盾**:同一笔分红(如 -9148.25)同时存在 `MarkClosePnl``DividendIn` 两个字段里。
---
## 三、MarkClosePnl 含分红:四个场景对照表(代码 + 数据佐证)
`MarkClosePnl` 在四个场景下的计算来源不同,但**全部含分红**:
| 场景 | EventType | MarkClosePnl 计算位置 | 算法 | 含分红 |
|------|-----------|---------------------|------|--------|
| 手动互换 | 3 | 前端 `calcFloatClosePnl:140`(后端 `SwapIncome` 直接存)| `价差 + 费 + DividendIn` | **含** |
| 手动平仓 | 2 | 前端 `calcFloatClosePnl`(后端 `SwapUnwind` 直接存)| `价差 + 费 + DividendIn` | **含** |
| 自动互换(分红型) | 4 | 后端 `DealDividends:463` | `= PosiDividendSum`(分红本身)| **含**(就是分红本身)|
| 自动全平仓 | 2 | 后端 `AuotoSwapUnwind:1102``SwapDealService` 类)| `价差 + 费 + DividendIn` | **含** |
> 注意:后端 `SwapDealService:1102` 在 `AuotoSwapUnwind` 方法内,仅被 `SwapTradeAutoService` 调用(自动全平仓,如到期自动平仓)。**手动互换/平仓的 MarkClosePnl 由前端算好后传入,后端直接存库不重算。**
### 真实数据佐证(2026-06 查询实测)
查询 swap_flow_event,对有分红(DividendIn!=0)的事件看 `MarkClosePnl``DividendIn``价差(期末-期初)`
**A组:MarkClosePnl == DividendIn(差值=0,纯分红,无价差)**
| trade | EventType | 标的 | MarkClosePnl | DividendIn | 差值 | 价差(期末-期初) |
|-------|-----------|------|--------------|------------|------|----------------|
| 1854 | 4(自动互换) | 180205.IB | -18.95 | -18.95 | **0** | **0** |
| 1854 | 3(互换) | 180205.IB | 18.00 | 18.00 | **0** | **0** |
| 1868 | 3(互换) | 210210.IB | 103.346 | 103.346 | **0** | **0** |
| 1877 | 3(互换) | 210210.IB | -1033.46 | -1033.46 | **0** | **0** |
| 1885 | 3(互换) | 180205.IB | -9148.25 | -9148.25 | **0** | **0** |
| 1891 | 4(自动互换) | 180205.IB | -9.51 | -9.51 | **0** | **0** |
**结论**:当价差=0(期末价=期初价)时,`MarkClosePnl` 完全等于 `DividendIn`。这证明 `MarkClosePnl = 价差(0) + 费(0) + 分红 = 分红`**MarkClosePnl 含分红,铁证成立**。自动互换(EventType=4)同样成立。
> B组(差值≠0,如 1891-3互换 MarkClosePnl=395594.949)的差值是"价差+费"成分,但这些是历史脏数据(期末价被多除100导致价差异常),不影响"MarkClosePnl 含分红"的结论——只是这部分价差也是脏的。
---
## 四、重复次数:确定 2 次(非 2~3 次)
### 数据流图(以 trade 1885 纯分红互换为例,分红 = -9148.25)
```
【客观源头】
PosiQuantity = 100万, totalInterest = 每张分红率
期末价 = 期初价 = 1.00 (价差=0)
【第二层计算】
DividendIn = 100万 × totalInterest × 方向 = -9148.25
价差盈亏 = 0
交易费 = 0
【第三层入库 swap_flow_event】 ⚠️ 问题在这里
┌──────────────────────────────────────────────┐
│ MarkClosePnl = 价差(0) + 费(0) + 分红(-9148.25) │ ◄── 分红第①次存入
│ = -9148.25 │
│ DividendIn = -9148.25 │ ◄── 分红第②次存入(同一笔!)
└──────────────────────────────────────────────┘
│ 日终汇总
【eod_swap_position】 (实测确认)
TdCloseMtmPnl = Σ MarkClosePnl = -9148.25 (含分红)
TdCloseDividend = Σ DividendIn = -9148.25 (分红)
【算 RealizedPnl 已实现盈亏】 ⚠️⚠️ 重复累加
```
### 各层级重复次数(确定 2 次)
3 处代码分属**两个不同层级**,每个层级各自独立计算,但**每个层级分红都是 2 次**。不是叠加成 3 次。
| 聚合对象 | 代码位置 | 公式 | 分红算几次 |
|---------|---------|------|-----------|
| 单持仓 `eod_swap_position.RealizedPnl` | `1490` + `1512` | `RealizedPnl = eod.RealizedPnl + TdCloseMtmPnl``if(PosiDividendSum==0) RealizedPnl += TdCloseDividend` | **2 次**Mtm含1次 + Dividend加1次)|
| 框架合约 `eod_swap.TdRealizedPnL`(当日) | `1866` | `TdRealizedPnL += TdCloseMtmPnl + TdCloseDividend` | **2 次**Mtm + Dividend|
| 框架合约 `eod_swap.RealizedPnL`(累计) | `1869` | `RealizedPnL = Σ(RealizedMtmPnL + RealizedDividend + RealizedFee + ...)` | **2 次**RealizedMtmPnL + RealizedDividend|
**为什么不是 3 次**:第1869行的 `eod_swap.RealizedPnL` **不读取**单持仓的 `eod_swap_position.RealizedPnl`,而是用 `RealizedMtmPnL + RealizedDividend` **独立重新聚合**。所以它与1490/1512行不是叠加关系——它们是**两个不同层级的对象**(`eod_swap``eod_swap_position` 的上层聚合),各自都把分红算了 2 次。
```
层级关系:
eod_swap_position (单持仓, 1490/1512算RealizedPnl) ← 第1层,分红2次
│ 聚合
eod_swap (框架合约, 1866/1869重新聚合) ← 第2层,分红2次(不读第1层的RealizedPnl)
```
**根本原因只有一个**`MarkClosePnl`(→ `TdCloseMtmPnl`/`RealizedMtmPnL`)含分红,而 `DividendIn`(→ `TdCloseDividend`/`RealizedDividend`)又是同一笔分红。无论哪个层级,只要把"Mtm"和"Dividend"相加,分红就翻倍。
---
## 五、为什么会变成这样(演进过程,用 hash + 日期)
分红核算经历了一长串反复修改,每次补丁都在加剧或修正重复:
| 时间 | 提交 | 改动 | 效果 |
|------|------|------|------|
| 2024-05-09 | `f9d8a256` | 山证基线,MarkClosePnl 含分红 | 埋下根源 |
| 2025-05-28 | `083848fb` | 审核流程重构,丢 eventType 分支 | 引入互换扣本金bug |
| 2026-06-25 | `1aba5cca` | DealDividends 加 SwapPositionValue/RealizedPnl 调整 | 语句顺序错(EQD-6290) |
| 2026-06-25 | `90b66922` | 修语句顺序 + 改全量重算口径 | 守恒修复✓ |
| 2026-06-25 | `2140a97f` | 加 `RealizedPnl += TdCloseDividend` | **加剧重复!**(以为分红漏算,其实Mtm已含)|
| 2026-06-26 | `1c9b28ff` | 互换审核不扣本金 | 修互换扣本金bug ✓ |
---
## 六、修复方向
判断标准:**让每个字段语义单一、职责不重叠**。
### 方向A(推荐,治本):让 MarkClosePnl 不含分红
让字段职责分离——Mtm 只管价差,Dividend 只管分红:
| 改动点 | 改前 | 改后 |
|--------|------|------|
| 前端 `calcFloatClosePnl:140`(手动互换/平仓)| `MarkClosePnl = 价差 + 费 + DividendIn` | `MarkClosePnl = 价差 + 费`(去掉 DividendIn|
| 后端 `AuotoSwapUnwind:1102`(自动平仓)| `MarkClosePnl = 价差 + 费 + DividendIn` | 去掉 `+ DividendIn` |
| 后端 `DealDividends:463`(自动互换)| `MarkClosePnl = PosiDividendSum` | `MarkClosePnl = 0`(自动互换无价差,分红走 DividendIn|
| `2140a97f` 新增(1512行)| `RealizedPnl += TdCloseDividend` | **保留**(此时 Mtm 不含分红,加 Dividend 才正确)|
| 1490行 | `RealizedPnl = eod.RealizedPnl + TdCloseMtmPnl` | 保留(Mtm 现在是纯价差)|
| 1866/1869行 | `TdCloseMtmPnl + TdCloseDividend` | 保留(现在 Mtm 不含分红,相加正确)|
**效果**
```
MarkClosePnl = 纯价差(0) ← 只管价差
DividendIn = 分红(-9148.25) ← 只管分红
RealizedPnl = TdCloseMtmPnl(0) + TdCloseDividend(-9148.25) = -9148.25 ← 各加1次,不重复 ✓
```
**风险**:要排查"前端盯市盈亏展示列"是否期望含分红(如果某处展示 MarkClosePnl 给用户看,去掉分红后显示会变)。需要回归测试前端展示。
### 方向B(保守):保留 MarkClosePnl 含分红,改汇总公式
| 改动点 | 改法 |
|--------|------|
| 1866行 | `TdRealizedPnL += TdCloseMtmPnl`(去掉 `+ TdCloseDividend`,因为 Mtm 已含)|
| 1869行 | `RealizedPnL = Σ(RealizedMtmPnL + RealizedFee + ...)`(去掉 `+ RealizedDividend`|
| `2140a97f`1512行)| **撤销** `RealizedPnl += TdCloseDividend`1490 的 Mtm 已含)|
**风险**`TdCloseDividend`/`RealizedDividend` 可能被分红明细报表单独展示,去掉后那部分变0。
---
## 七、建议
1. **优先对齐"MarkClosePnl 该不该含分红"这个根本问题**。这是所有重复的根源。
2. 达成共识后按**方向A**改(字段职责分离)。方向A改完后,`2140a97f` 这类补丁就不再需要,逻辑能稳定下来。
3. 改完后用"守恒检验"验证:`ΔSwapPositionValue + ΔRealizedPnl == 0`(见附录 SQL)。
4. **这块缺少自动化测试**(前端无测试、后端 Swap 单测只覆盖 EodPositionService)。建议补一个"分红守恒"后端单测兜底,避免反复打补丁。
---
## 附录A:验证 SQL
### A.1 验证 MarkClosePnl 含分红(含价差佐证)
```sql
SELECT
fe.SwapTradeId AS trade_id,
fe.EventType AS , -- 2=平仓, 3=互换, 4=自动互换
fe.UnderlyingCode AS ,
fe.MarkClosePnl AS _入库,
fe.DividendIn AS _入库,
fe.MarkClosePnl - fe.DividendIn AS _纯价差和费,
sp.PosiGrossPrice AS ,
fe.TradingAmountAvg AS ,
fe.TradingAmountAvg - sp.PosiGrossPrice AS _期末减期初,
fe.TradingFee AS
FROM swap_flow_event fe
JOIN swap_position sp ON sp.SwapTradeId = fe.SwapTradeId AND sp.IsInitial = 1 AND sp.UnderlyingCode = fe.UnderlyingCode
WHERE fe.DividendIn != 0
ORDER BY fe.SwapTradeId, fe.id DESC
LIMIT 20;
```
**判断**:若某行 `差值=0``价差=0` → 证明无价差互换时 MarkClosePnl 完全等于分红。
### A.2 验证分红互换守恒(修复后用)
```sql
SELECT
cur.SwapTradeId AS trade_id,
cur.ValueDate AS ,
cur.SwapPositionValue AS ,
pre.SwapPositionValue AS ,
cur.SwapPositionValue - pre.SwapPositionValue AS Δ,
cur.RealizedPnl AS ,
pre.RealizedPnl AS ,
cur.RealizedPnl - pre.RealizedPnl AS Δ,
(cur.SwapPositionValue - pre.SwapPositionValue) + (cur.RealizedPnl - pre.RealizedPnl) AS _应接近0,
cur.TdCloseDividend AS _参考
FROM eod_swap_position cur
JOIN eod_swap_position pre
ON pre.SwapTradeId = cur.SwapTradeId AND pre.UnderlyingCode = cur.UnderlyingCode
AND pre.ValueDate < cur.ValueDate
AND pre.id = (SELECT MAX(id) FROM eod_swap_position
WHERE SwapTradeId = cur.SwapTradeId AND UnderlyingCode = cur.UnderlyingCode AND ValueDate < cur.ValueDate)
WHERE cur.SwapTradeId IN (
SELECT DISTINCT fe.SwapTradeId FROM swap_flow_event fe
WHERE fe.EventType = 4 AND fe.DividendIn != 0 AND fe.UnderlyingCode IS NOT NULL
)
AND cur.UnderlyingCode IS NOT NULL
ORDER BY cur.SwapTradeId DESC, cur.ValueDate DESC LIMIT 15;
```
**判断**:守恒检验列接近0 → 修复成功。
---
## 附录B:关键代码位置索引(精确到类:行号)
### B.1 MarkClosePnl 含分红(重复根源)
| 场景 | 文件:类:行号 | 算法 | 含分红 |
|------|-------------|------|--------|
| 手动互换 | `incomeSwapTrade.js`:`calcFloatClosePnl`:133-134 | `价差(CloseNotionalValue×...) + 费 + DividendIn` | **含** |
| 手动平仓 | `unwindSwapTrade.js`:`calcFloatClosePnl`:203-205 | `价差(CloseQty×...) + 费×floatRatio×-1 + DividendIn` | **含** |
| 自动互换(分红型) | `SwapEodPositionService.cs`:`DealDividends`:463 | `= PosiDividendSum`(分红本身)| **含** |
| 自动全平仓 | `SwapDealService.cs`:`AuotoSwapUnwind`:1101-1102 | `价差 + 费 + DividendIn` | **含** |
| 多空组合合并事件 | `SwapFlowEventService.cs`:`MergePageEvent`:287 | `= PayMarkUnwindPnl`(待确认来源)| 待确认 |
### B.2 已实现盈亏累加点(重复发生处)
| 聚合对象 | 文件:类:行号 | 公式 | 分红次数 |
|---------|-------------|------|---------|
| 单持仓 `RealizedPnl` | `SwapEodPositionService.cs`:`UpdateEodPosition`:1490 | `= eod.RealizedPnl + TdCloseMtmPnl` | 含分红(Mtm) |
| 单持仓 `RealizedPnl`(加分红) | `SwapEodPositionService.cs`:`UpdateEodPosition`:1512`2140a97f`| `if(PosiDividendSum==0) += TdCloseDividend` | 又加分红 |
| 框架合约 `TdRealizedPnL` | `SwapEodPositionService.cs`:`SaveEodSwap`:1866 | `+= TdCloseMtmPnl + TdCloseDividend` | 翻倍 |
| 框架合约 `RealizedPnL` | `SwapEodPositionService.cs`:`SaveEodSwap`:1869 | `= Σ(RealizedMtmPnL + RealizedDividend + ...)` | 翻倍 |
### B.3 totalInterest 计算链路
| 环节 | 文件:类:行号 | 算法 |
|------|-------------|------|
| totalInterest | `BondPaymentController.cs`:`GetBondPayMentInterest`:91-100 | `Σ payment_interest × 0.01 / (1+税) × (1-税)` |
| DividendIn(互换) | `incomeSwapTrade.js`:`getDivindIn`:197 | `PositionQty × totalInterest × 方向` |
| DividendIn(平仓) | `unwindSwapTrade.js`:`getDivindIn`:286 | `CloseQty × (totalInterest - consumedDividendInterest) × 方向` |
---
## 附录CcalcFloatClosePnl 两处不一致(技术债,待确认)
平仓页和互换页的 `calcFloatClosePnl` 存在 **4 处不一致**,属于重复代码各自实现导致,哪个对需业务确认:
```js
// floatRatio / longRatio 含义:
// floatRatio = (PayDirection == 1) ? 1 : -1; // 收取=1, 支付=-1 (本方收支方向)
// longRatio = (PositionType == 1) ? 1 : -1; // 多头=1, 空头=-1 (本方多空方向)
```
| 维度 | 平仓 `unwindSwapTrade.js:203-205` | 互换 `incomeSwapTrade.js:133-134` | 说明 |
|------|----------------------------------|----------------------------------|------|
| 价差基数 | `CloseQty`(数量)| `CloseNotionalValue`(名义本金)| 量纲不同 |
| 价差方向 | `× floatRatio × longRatio` | `× floatRatio`**无 longRatio**| ⚠️ 互换若支持空头可能算错 |
| 四舍五入 | `Math.round×10000/10000` + `toFixed(2)` | 无 | ⚠️ 互换无取整,精度风险 |
| 费用方向 | `(费) × floatRatio × -1` | `费`(**无方向**)| ⚠️ 互换支付方费用方向可能没反转 |
| 分红 | `+ DividendIn` | `+ DividendIn` | 一致(都含分红)|
**建议**:统一为公共函数(参考 `changeUnderlyingPrice` 两页一字不差的做法),方向处理对齐到更完整的平仓版本(含 `longRatio` + `floatRatio*-1`)。但需业务确认互换场景下 `PayDirection` 是否已隐含多空方向。
---
## 附录D:测试策略
### 现状
- 后端有测试基础设施:`UnitTestProject/Modules/SwapModule/SwapEodPositionServiceIntegrationTest.cs`MSTest,集成测试)
- **但现有测试基础薄弱**:现有测试(如 `TestDealInterests_ViaSwapPositionCompose`)虽调用了真实业务代码 `service.SwapPositionCompose(...)`,但 catch 异常后 `Assert.IsTrue(true)` 就算通过,**没有对输出的 eod 持仓字段做任何断言**,属于"能跑不崩就算过"的烟雾测试,无法验证业务正确性。
- 前端无测试框架(无 jest/mocha/playwright
### 教训:测试必须调用真实业务代码
> 曾尝试写"构造字段对象自己断言"的纯逻辑测试(如 `TestDividendConservation_*`),但这类测试**自己造数据自己测**,不调用 `SwapEodPositionService` 的任何方法,是**无意义的伪测试**——业务代码写错也会通过。已删除。真正的测试必须:调用真实业务方法 → 查回输出字段 → 断言字段关系。
### 后续测试方向
- **参考 golden source 分支**`glms/feature/refactor-swap-event-testable`(该分支有可复用的 Swap 事件测试数据构造,后续补分红守恒测试时以此为基础)
- 真正有意义的守恒测试需要:
1. 用 golden source 的测试数据工厂构造一笔带分红的互换交易(`trade` + `swap_position` + `bond_payment` + `swap_flow_event`,多张表自洽)
2. 调用真实的 `SwapPositionCompose``DealDividends`
3. 查回 `eod_swap_position`,断言 `ΔSwapPositionValue + ΔRealizedPnl == 0`
- 这是独立的工程任务,需要专门投入时间,不适合在排查任务里匆忙做
### 当前验证手段(测试补全前)
- **数据验证SQL**(附录A.1/A.2):纯查询,不改代码,可快速验证线上数据是否重复/守恒
- **修复后回归**:用附录A的SQL在修复前后各跑一次,对比 `TdCloseMtmPnl + TdCloseDividend` 是否还有翻倍