# 互换分红损益:字段语义与重复计算分析 > 本文档分析互换(收益互换)"分红/付息损益"核算链路中存在的字段语义混乱与分红重复计算问题。 > 用真实交易数据佐证,供团队对齐字段语义、确定修复方案。 > 成文于 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) × 方向` | --- ## 附录C:calcFloatClosePnl 两处不一致(技术债,待确认) 平仓页和互换页的 `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` 是否还有翻倍