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

20 KiB
Raw Permalink Blame History

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

本文档分析互换(收益互换)"分红/付息损益"核算链路中存在的字段语义混乱与分红重复计算问题。 用真实交易数据佐证,供团队对齐字段语义、确定修复方案。 成文于 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.GetBondPayMentInterestBondPaymentController.cs:91-100),前端 getDivindInincomeSwapTrade.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)同时存在 MarkClosePnlDividendIn 两个字段里。


三、MarkClosePnl 含分红:四个场景对照表(代码 + 数据佐证)

MarkClosePnl 在四个场景下的计算来源不同,但全部含分红

场景 EventType MarkClosePnl 计算位置 算法 含分红
手动互换 3 前端 calcFloatClosePnl:140(后端 SwapIncome 直接存) 价差 + 费 + DividendIn
手动平仓 2 前端 calcFloatClosePnl(后端 SwapUnwind 直接存) 价差 + 费 + DividendIn
自动互换(分红型) 4 后端 DealDividends:463 = PosiDividendSum(分红本身) (就是分红本身)
自动全平仓 2 后端 AuotoSwapUnwind:1102SwapDealService 类) 价差 + 费 + DividendIn

注意:后端 SwapDealService:1102AuotoSwapUnwind 方法内,仅被 SwapTradeAutoService 调用(自动全平仓,如到期自动平仓)。手动互换/平仓的 MarkClosePnl 由前端算好后传入,后端直接存库不重算。

真实数据佐证(2026-06 查询实测)

查询 swap_flow_event,对有分红(DividendIn!=0)的事件看 MarkClosePnlDividendIn价差(期末-期初)

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 + TdCloseMtmPnlif(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_swapeod_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
2140a97f1512行) 撤销 RealizedPnl += TdCloseDividend1490 的 Mtm 已含)

风险TdCloseDividend/RealizedDividend 可能被分红明细报表单独展示,去掉后那部分变0。


七、建议

  1. 优先对齐"MarkClosePnl 该不该含分红"这个根本问题。这是所有重复的根源。
  2. 达成共识后按方向A改(字段职责分离)。方向A改完后,2140a97f 这类补丁就不再需要,逻辑能稳定下来。
  3. 改完后用"守恒检验"验证:ΔSwapPositionValue + ΔRealizedPnl == 0(见附录 SQL)。
  4. 这块缺少自动化测试(前端无测试、后端 Swap 单测只覆盖 EodPositionService)。建议补一个"分红守恒"后端单测兜底,避免反复打补丁。

附录A:验证 SQL

A.1 验证 MarkClosePnl 含分红(含价差佐证)

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 验证分红互换守恒(修复后用)

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:15122140a97f 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 处不一致,属于重复代码各自实现导致,哪个对需业务确认:

// 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.csMSTest,集成测试)
  • 但现有测试基础薄弱:现有测试(如 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. 调用真实的 SwapPositionComposeDealDividends
    3. 查回 eod_swap_position,断言 ΔSwapPositionValue + ΔRealizedPnl == 0
  • 这是独立的工程任务,需要专门投入时间,不适合在排查任务里匆忙做

当前验证手段(测试补全前)

  • 数据验证SQL(附录A.1/A.2):纯查询,不改代码,可快速验证线上数据是否重复/守恒
  • 修复后回归:用附录A的SQL在修复前后各跑一次,对比 TdCloseMtmPnl + TdCloseDividend 是否还有翻倍