diff --git a/项目文档/互换分红损益字段语义与重复计算分析.md b/项目文档/互换分红损益字段语义与重复计算分析.md new file mode 100644 index 00000000..7509ee27 --- /dev/null +++ b/项目文档/互换分红损益字段语义与重复计算分析.md @@ -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) × 方向` | + +--- + +## 附录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` 是否还有翻倍