Files
zszq-trs/项目文档/互换分红损益字段语义与重复计算分析.md
hjhan 9e6af84e47 新增文档:互换分红损益字段语义与重复计算分析
分析互换分红/付息损益核算链路中的字段语义混乱与分红重复计算问题,
用真实交易数据佐证,供团队对齐字段语义、确定修复方案:

- 核心结论: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)
2026-06-26 07:33:36 +08:00

356 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 互换分红损益:字段语义与重复计算分析
> 本文档分析互换(收益互换)"分红/付息损益"核算链路中存在的字段语义混乱与分红重复计算问题。
> 用真实交易数据佐证,供团队对齐字段语义、确定修复方案。
> 成文于 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` 是否还有翻倍