分析互换分红/付息损益核算链路中的字段语义混乱与分红重复计算问题, 用真实交易数据佐证,供团队对齐字段语义、确定修复方案: - 核心结论: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)
20 KiB
互换分红损益:字段语义与重复计算分析
本文档分析互换(收益互换)"分红/付息损益"核算链路中存在的字段语义混乱与分红重复计算问题。 用真实交易数据佐证,供团队对齐字段语义、确定修复方案。 成文于 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。
七、建议
- 优先对齐"MarkClosePnl 该不该含分红"这个根本问题。这是所有重复的根源。
- 达成共识后按方向A改(字段职责分离)。方向A改完后,
2140a97f这类补丁就不再需要,逻辑能稳定下来。 - 改完后用"守恒检验"验证:
ΔSwapPositionValue + ΔRealizedPnl == 0(见附录 SQL)。 - 这块缺少自动化测试(前端无测试、后端 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: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 处不一致,属于重复代码各自实现导致,哪个对需业务确认:
// 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 事件测试数据构造,后续补分红守恒测试时以此为基础) - 真正有意义的守恒测试需要:
- 用 golden source 的测试数据工厂构造一笔带分红的互换交易(
trade+swap_position+bond_payment+swap_flow_event,多张表自洽) - 调用真实的
SwapPositionCompose或DealDividends - 查回
eod_swap_position,断言ΔSwapPositionValue + ΔRealizedPnl == 0
- 用 golden source 的测试数据工厂构造一笔带分红的互换交易(
- 这是独立的工程任务,需要专门投入时间,不适合在排查任务里匆忙做
当前验证手段(测试补全前)
- 数据验证SQL(附录A.1/A.2):纯查询,不改代码,可快速验证线上数据是否重复/守恒
- 修复后回归:用附录A的SQL在修复前后各跑一次,对比
TdCloseMtmPnl + TdCloseDividend是否还有翻倍