From a153a1d1a63560a77a86365e2d7a336e7200fb5b Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 21 Jul 2026 15:28:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor(swap-eod):=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=E6=9C=9F=E6=9C=AB=E5=A4=B4=E5=AF=B8=E7=BA=AF=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=B9=B6=E6=94=BE=E5=AE=BD=E5=8F=AF=E8=A7=81=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SetFixedLegRealizedPnl 替换4处复制粘贴(清理L1296双分号); NormalizeInterestSignForReport 替换16行内联报表分支; CalculateWeightedMarginInterest private->public static。均为纯静态无实例依赖,配套无库单测锁定行为。 --- .../SwapModule/SwapFixedLegRealizedPnlTest.cs | 162 +++++++++++++ .../SwapReportInterestSignNormalizeTest.cs | 212 ++++++++++++++++++ .../SwapWeightedMarginInterestTest.cs | 150 +++++++++++++ .../SwapModule/SwapEodPositionService.cs | 63 ++++-- 4 files changed, 566 insertions(+), 21 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/SwapFixedLegRealizedPnlTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/SwapReportInterestSignNormalizeTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/SwapWeightedMarginInterestTest.cs diff --git a/UnitTestProject/Modules/SwapModule/SwapFixedLegRealizedPnlTest.cs b/UnitTestProject/Modules/SwapModule/SwapFixedLegRealizedPnlTest.cs new file mode 100644 index 00000000..2170a4f6 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapFixedLegRealizedPnlTest.cs @@ -0,0 +1,162 @@ +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapEodPositionService.SetFixedLegRealizedPnl 的回归测试。 + /// ----------------------------------------------------------------- + /// 守卫提交 f4ffe710 "fix(swap): 修复期末头寸已实现盈亏计算问题"。 + /// + /// 4 处 SaveAutoEodInterestPosition/SaveEodInterestPosition 路径原本各自复制粘贴: + /// newEodPayPosition.RealizedPnl = RealizedInterest + RealizedInterestFee + /// 其中 L1296 还遗留了双分号笔误 ";;"。抽为单一纯函数后消除复制粘贴风险, + /// 并锁定"固定利息腿累计已实现盈亏 = 累计利息 + 累计利息费用"口径。 + /// + /// 单测覆盖:正/负/零/混合符号、大额、InterestFee 为零等场景。 + /// + [TestClass] + public class SwapFixedLegRealizedPnlTest + { + // ================================================================ + // 场景1:收取方向,利息与利息费用均为正 + // ================================================================ + [TestMethod] + public void 收取方向_利息与费用均为正_求和() + { + var pos = NewPosition(realizedInterest: 1000m, realizedInterestFee: 200m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(1200m, pos.RealizedPnl, 0.0001m, + "1000 + 200 = 1200"); + } + + // ================================================================ + // 场景2:支付方向,利息与利息费用均为负 + // ================================================================ + [TestMethod] + public void 支付方向_利息与费用均为负_求和() + { + var pos = NewPosition(realizedInterest: -1000m, realizedInterestFee: -200m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(-1200m, pos.RealizedPnl, 0.0001m, + "-1000 + (-200) = -1200"); + } + + // ================================================================ + // 场景3:利息费用为零 —— RealizedPnl = RealizedInterest + // 回归场景:部分路径利息费用未发生,确保不误乘/不丢值 + // ================================================================ + [TestMethod] + public void 利息费用为零_盈亏等于利息() + { + var pos = NewPosition(realizedInterest: 500m, realizedInterestFee: 0m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(500m, pos.RealizedPnl, 0.0001m, + "利息费用=0 时 RealizedPnl = RealizedInterest"); + } + + // ================================================================ + // 场景4:利息为零 —— RealizedPnl = RealizedInterestFee + // ================================================================ + [TestMethod] + public void 利息为零_盈亏等于利息费用() + { + var pos = NewPosition(realizedInterest: 0m, realizedInterestFee: 300m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(300m, pos.RealizedPnl, 0.0001m, + "利息=0 时 RealizedPnl = RealizedInterestFee"); + } + + // ================================================================ + // 场景5:两者均为零 —— RealizedPnl = 0 + // ================================================================ + [TestMethod] + public void 利息与费用均为零_盈亏为零() + { + var pos = NewPosition(realizedInterest: 0m, realizedInterestFee: 0m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(0m, pos.RealizedPnl, 0.0001m, + "两者均为 0 时 RealizedPnl = 0"); + } + + // ================================================================ + // 场景6:混合符号(利息负、利息费用正)—— 直接求和 + // 回归场景:避免有人误加 Math.Abs 或方向判断 + // ================================================================ + [TestMethod] + public void 混合符号_利息负费用正_直接求和() + { + var pos = NewPosition(realizedInterest: -800m, realizedInterestFee: 100m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(-700m, pos.RealizedPnl, 0.0001m, + "-800 + 100 = -700,不引入 Abs/方向判断"); + } + + // ================================================================ + // 场景7:大额 —— 验证 decimal 精度无溢出 + // ================================================================ + [TestMethod] + public void 大额_decimal精度无溢出() + { + var pos = NewPosition(realizedInterest: 279_486_108.21m, realizedInterestFee: 13_668.02m); + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(279_499_776.23m, pos.RealizedPnl, 0.0001m, + "大额 decimal 求和精度保持"); + } + + // ================================================================ + // 场景8:null 入参 —— 抛 NullReferenceException(现状锚点) + // 生产代码未加 null 检查,直接解引用 position 抛 NRE。 + // 若未来改为 ArgumentNullException,此处需同步更新。 + // ================================================================ + [TestMethod] + public void Null入参_抛NullReferenceException() + { + Assert.ThrowsException(() => + SwapEodPositionService.SetFixedLegRealizedPnl(null!)); + } + + // ================================================================ + // 场景9:覆盖原值 —— 验证是赋值而非累加 + // 回归场景:防止有人误改为 += 导致重复计算 + // ================================================================ + [TestMethod] + public void 原有RealizedPnl被覆盖_非累加() + { + var pos = NewPosition(realizedInterest: 100m, realizedInterestFee: 50m); + pos.RealizedPnl = 9999m; // 预置一个非零旧值 + + SwapEodPositionService.SetFixedLegRealizedPnl(pos); + + Assert.AreEqual(150m, pos.RealizedPnl, 0.0001m, + "应直接覆盖为 150,而非累加旧值 9999"); + } + + // ================================================================ + // Helper:构造 eod_swap_position(只设置参与计算的 2 个字段) + // ================================================================ + private static eod_swap_position NewPosition( + decimal realizedInterest, + decimal realizedInterestFee) + { + return new eod_swap_position + { + RealizedInterest = realizedInterest, + RealizedInterestFee = realizedInterestFee + }; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapReportInterestSignNormalizeTest.cs b/UnitTestProject/Modules/SwapModule/SwapReportInterestSignNormalizeTest.cs new file mode 100644 index 00000000..f6739ac8 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapReportInterestSignNormalizeTest.cs @@ -0,0 +1,212 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapEodPositionService.NormalizeInterestSignForReport 的回归测试。 + /// ----------------------------------------------------------------- + /// 守卫提交 f4ffe710 中报表分支的符号归一化逻辑(原内联于 SearchEodSwapList)。 + /// + /// 业务口径: + /// - 仅当 InterestDirection > 0 时执行(兼容历史 0 方向脏数据) + /// - 普通利息腿:收取为正、支付为负(interestRatio = Direction==收取 ? 1 : -1) + /// - 预付金腿(初始预付金/追加预付金):利息方向与保证金本金方向相反,interestRatio 取反 + /// - TdCloseInterest / RealizedInterest 统一按 Math.Abs × interestRatio 重写 + /// - RealizedPnl 重算为 RealizedInterest + RealizedInterestFee(兼容历史未同步落库) + /// + /// 抽为 public static 纯函数以支持无库单测。 + /// + [TestClass] + public class SwapReportInterestSignNormalizeTest + { + // ================================================================ + // 场景1:普通利息腿收取方向 → 利息维持正号 + // TdCloseInterest=-1000(历史脏数据符号错) → 归一化为 +1000 + // RealizedInterest=-2000 → +2000 + // RealizedPnl = 2000 + 100 = 2100 + // ================================================================ + [TestMethod] + public void 普通利息腿_收取方向_利息归一化为正() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.收取, + tdCloseInterest: -1000m, + realizedInterest: -2000m, + realizedInterestFee: 100m); + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(1000m, pos.TdCloseInterest, 0.0001m, "TdCloseInterest 应归一化为 +1000"); + Assert.AreEqual(2000m, pos.RealizedInterest, 0.0001m, "RealizedInterest 应归一化为 +2000"); + Assert.AreEqual(2100m, pos.RealizedPnl, 0.0001m, "RealizedPnl = 2000 + 100 = 2100"); + } + + // ================================================================ + // 场景2:普通利息腿支付方向 → 利息归一化为负 + // TdCloseInterest=1000(历史脏数据符号错) → 归一化为 -1000 + // RealizedInterest=2000 → -2000 + // RealizedPnl = -2000 + 100 = -1900 + // ================================================================ + [TestMethod] + public void 普通利息腿_支付方向_利息归一化为负() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.支付, + tdCloseInterest: 1000m, + realizedInterest: 2000m, + realizedInterestFee: 100m); + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(-1000m, pos.TdCloseInterest, 0.0001m, "TdCloseInterest 应归一化为 -1000"); + Assert.AreEqual(-2000m, pos.RealizedInterest, 0.0001m, "RealizedInterest 应归一化为 -2000"); + Assert.AreEqual(-1900m, pos.RealizedPnl, 0.0001m, "RealizedPnl = -2000 + 100 = -1900"); + } + + // ================================================================ + // 场景3:预付金腿收取方向 → 利息方向反向为负 + // 原因:预付金腿的利息方向与保证金本金方向相反 + // TdCloseInterest=1000 → -1000 + // RealizedInterest=2000 → -2000 + // ================================================================ + [TestMethod] + public void 预付金腿_收取方向_利息反向为负() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.收取, + tdCloseInterest: 1000m, + realizedInterest: 2000m, + realizedInterestFee: 0m); + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(-1000m, pos.TdCloseInterest, 0.0001m, + "预付金腿收取方向:利息反向为负"); + Assert.AreEqual(-2000m, pos.RealizedInterest, 0.0001m, + "预付金腿收取方向:累计利息反向为负"); + Assert.AreEqual(-2000m, pos.RealizedPnl, 0.0001m, + "RealizedPnl = -2000 + 0 = -2000"); + } + + // ================================================================ + // 场景4:预付金腿支付方向 → 利息方向反向为正 + // ================================================================ + [TestMethod] + public void 预付金腿_支付方向_利息反向为正() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.追加预付金, + interestDirection: (int)SwapDirectionEnum.支付, + tdCloseInterest: -1000m, + realizedInterest: -2000m, + realizedInterestFee: 50m); + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(1000m, pos.TdCloseInterest, 0.0001m, + "预付金腿支付方向:利息反向为正"); + Assert.AreEqual(2000m, pos.RealizedInterest, 0.0001m, + "预付金腿支付方向:累计利息反向为正"); + Assert.AreEqual(2050m, pos.RealizedPnl, 0.0001m, + "RealizedPnl = 2000 + 50 = 2050"); + } + + // ================================================================ + // 场景5:InterestDirection=0 → 不处理(兼容历史 0 方向脏数据) + // 所有字段保持原值不变 + // ================================================================ + [TestMethod] + public void 方向为零_不处理_字段保持原值() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: 0, + tdCloseInterest: -999m, + realizedInterest: -888m, + realizedInterestFee: 77m); + pos.RealizedPnl = 555m; // 预置旧值 + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(-999m, pos.TdCloseInterest, 0.0001m, "方向=0:TdCloseInterest 不变"); + Assert.AreEqual(-888m, pos.RealizedInterest, 0.0001m, "方向=0:RealizedInterest 不变"); + Assert.AreEqual(555m, pos.RealizedPnl, 0.0001m, "方向=0:RealizedPnl 不重算"); + } + + // ================================================================ + // 场景6:InterestDirection 为负 → 不处理(防御性,对应原 "> 0" 判断) + // ================================================================ + [TestMethod] + public void 方向为负_不处理_字段保持原值() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: -1, + tdCloseInterest: -999m, + realizedInterest: -888m, + realizedInterestFee: 77m); + pos.RealizedPnl = 555m; + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(-999m, pos.TdCloseInterest, 0.0001m, "方向<0:TdCloseInterest 不变"); + Assert.AreEqual(555m, pos.RealizedPnl, 0.0001m, "方向<0:RealizedPnl 不重算"); + } + + // ================================================================ + // 场景7:利息为零 → Math.Abs(0)=0,归一化后仍为 0,RealizedPnl=费用 + // ================================================================ + [TestMethod] + public void 利息为零_归一化后仍为零_盈亏等于费用() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.收取, + tdCloseInterest: 0m, + realizedInterest: 0m, + realizedInterestFee: 300m); + + SwapEodPositionService.NormalizeInterestSignForReport(pos); + + Assert.AreEqual(0m, pos.TdCloseInterest, 0.0001m); + Assert.AreEqual(0m, pos.RealizedInterest, 0.0001m); + Assert.AreEqual(300m, pos.RealizedPnl, 0.0001m, "RealizedPnl = 0 + 300 = 300"); + } + + // ================================================================ + // 场景8:null 入参 —— 抛 NullReferenceException(现状锚点) + // 生产代码未加 null 检查,InterestDirection 解引用即 NRE。 + // 若未来改为 ArgumentNullException,此处需同步更新。 + // ================================================================ + [TestMethod] + public void Null入参_抛NullReferenceException() + { + Assert.ThrowsException(() => + SwapEodPositionService.NormalizeInterestSignForReport(null!)); + } + + // ================================================================ + // Helper:构造 eod_swap_position + // ================================================================ + private static eod_swap_position NewPosition( + int interestMode, + int interestDirection, + decimal tdCloseInterest, + decimal realizedInterest, + decimal realizedInterestFee) + { + return new eod_swap_position + { + InterestMode = interestMode, + InterestDirection = interestDirection, + TdCloseInterest = tdCloseInterest, + RealizedInterest = realizedInterest, + RealizedInterestFee = realizedInterestFee + }; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapWeightedMarginInterestTest.cs b/UnitTestProject/Modules/SwapModule/SwapWeightedMarginInterestTest.cs new file mode 100644 index 00000000..da901be3 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapWeightedMarginInterestTest.cs @@ -0,0 +1,150 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapEodPositionService.CalculateWeightedMarginInterest 的回归测试。 + /// ----------------------------------------------------------------- + /// 守卫提交 a0f8dc9d "refactor(SwapModule): 简化预付金利息计算逻辑"。 + /// + /// 旧实现:按 InterestPrincipalFix 绝对值加权平均利率 × 总本金,对方向不敏感, + /// 当收取/支付双腿并存时会把支付方向的利息错误计为收益。 + /// 新实现:InterestIncomeSum 已是各腿利息金额,按方向(收取=+1,支付=-1)轧差求和。 + /// + /// 抽为 public static 纯函数以支持无库单测。本测试锁定方向轧差契约。 + /// + [TestClass] + public class SwapWeightedMarginInterestTest + { + // ================================================================ + // 场景1:空集合 → 0(Sum 空序列默认值) + // ================================================================ + [TestMethod] + public void 空集合_返回0() + { + var result = SwapEodPositionService.CalculateWeightedMarginInterest( + Enumerable.Empty()); + + Assert.AreEqual(0m, result, 0.0001m, "空集合轧差应为 0"); + } + + // ================================================================ + // 场景2:单腿收取 → InterestIncomeSum 原值 + // ================================================================ + [TestMethod] + public void 单腿收取_利息原值计入() + { + var margins = new[] + { + NewMargin(interestDirection: (int)SwapDirectionEnum.收取, interestIncomeSum: 1000m) + }; + + var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins); + + Assert.AreEqual(1000m, result, 0.0001m, "单腿收取:+1000"); + } + + // ================================================================ + // 场景3:单腿支付 → InterestIncomeSum 取负 + // ================================================================ + [TestMethod] + public void 单腿支付_利息取负计入() + { + var margins = new[] + { + NewMargin(interestDirection: (int)SwapDirectionEnum.支付, interestIncomeSum: 1000m) + }; + + var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins); + + Assert.AreEqual(-1000m, result, 0.0001m, "单腿支付:-1000"); + } + + // ================================================================ + // 场景4:双腿轧差(收取 1000 + 支付 600 → 400) + // 旧 bug:按本金加权会忽略方向,结果不是 400 + // ================================================================ + [TestMethod] + public void 双腿轧差_收取大于支付_净额为正() + { + var margins = new[] + { + NewMargin(interestDirection: (int)SwapDirectionEnum.收取, interestIncomeSum: 1000m), + NewMargin(interestDirection: (int)SwapDirectionEnum.支付, interestIncomeSum: 600m) + }; + + var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins); + + Assert.AreEqual(400m, result, 0.0001m, "双腿轧差:1000 - 600 = 400"); + } + + // ================================================================ + // 场景5:本金为零但 InterestIncomeSum 非零 —— 回归旧 bug 关键场景 + // 旧实现:totalWeight=0 → 返回 0,丢失利息 + // 新实现:不看本金,按方向轧差 InterestIncomeSum + // ================================================================ + [TestMethod] + public void 本金为零_利息仍按方向轧差_不丢失() + { + var margins = new[] + { + NewMargin(interestDirection: (int)SwapDirectionEnum.收取, + interestIncomeSum: 500m, interestPrincipalFix: 0m), + NewMargin(interestDirection: (int)SwapDirectionEnum.支付, + interestIncomeSum: 200m, interestPrincipalFix: 0m) + }; + + var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins); + + Assert.AreEqual(300m, result, 0.0001m, + "本金为零时旧实现返回 0 丢失利息,新实现应按方向轧差 = 500 - 200 = 300"); + } + + // ================================================================ + // 场景6:多腿混合方向 —— 收取 100+200,支付 50+80 → 170 + // ================================================================ + [TestMethod] + public void 多腿混合方向_正确轧差() + { + var margins = new[] + { + NewMargin(interestDirection: (int)SwapDirectionEnum.收取, interestIncomeSum: 100m), + NewMargin(interestDirection: (int)SwapDirectionEnum.支付, interestIncomeSum: 50m), + NewMargin(interestDirection: (int)SwapDirectionEnum.收取, interestIncomeSum: 200m), + NewMargin(interestDirection: (int)SwapDirectionEnum.支付, interestIncomeSum: 80m) + }; + + var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins); + + Assert.AreEqual(170m, result, 0.0001m, "多腿轧差:(100+200) - (50+80) = 170"); + } + + // ================================================================ + // 场景7:null 入参防御 —— Sum 对 null 抛 ArgumentNullException + // 仅作行为锚点:若未来改为 null 安全,此处需同步更新 + // ================================================================ + [TestMethod] + public void Null入参_抛ArgumentNullException() + { + Assert.ThrowsException(() => + SwapEodPositionService.CalculateWeightedMarginInterest(null!)); + } + + // ================================================================ + // Helper:构造 eod_swap_position(只设置参与计算的 2 个字段 + 可选本金) + // ================================================================ + private static eod_swap_position NewMargin( + int interestDirection, + decimal interestIncomeSum, + decimal interestPrincipalFix = 0m) + { + return new eod_swap_position + { + InterestDirection = interestDirection, + InterestIncomeSum = interestIncomeSum, + InterestPrincipalFix = interestPrincipalFix + }; + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 44b5400b..55f0f88c 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1023,7 +1023,7 @@ namespace YLErp.Modules.SwapModule //累计已实现 newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio; newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee; - newEodPayPosition.RealizedPnl = newEodPayPosition.RealizedInterest + newEodPayPosition.RealizedInterestFee; + SetFixedLegRealizedPnl(newEodPayPosition); var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, position.InterestDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); @@ -1157,7 +1157,7 @@ namespace YLErp.Modules.SwapModule //累计已实现 newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio; newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee; - newEodPayPosition.RealizedPnl = newEodPayPosition.RealizedInterest + newEodPayPosition.RealizedInterestFee; + SetFixedLegRealizedPnl(newEodPayPosition); var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, position.InterestDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); @@ -1293,7 +1293,7 @@ namespace YLErp.Modules.SwapModule //累计已实现 newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio; newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee; - newEodPayPosition.RealizedPnl = newEodPayPosition.RealizedInterest + newEodPayPosition.RealizedInterestFee; ; + SetFixedLegRealizedPnl(newEodPayPosition); var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, position.InterestDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); @@ -1415,7 +1415,7 @@ namespace YLErp.Modules.SwapModule //累计已实现 newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio; newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee; - newEodPayPosition.RealizedPnl = newEodPayPosition.RealizedInterest + newEodPayPosition.RealizedInterestFee; + SetFixedLegRealizedPnl(newEodPayPosition); var currencyRate = GetCurrencyRate(td.QuoteCurrency, td.SettlementCurrency, valueDate, true, eodPayPosition.InterestDirection == (int)SwapDirectionEnum.收取 ? CurrencyRateType.Buy : CurrencyRateType.Sell); newEodPayPosition.TdCurrency = Convert.ToDecimal(currencyRate); @@ -2087,6 +2087,28 @@ namespace YLErp.Modules.SwapModule + position.RealizedInterestFee; } + /// + /// 风险报表符号归一化:把历史两种符号口径的 TdCloseInterest/RealizedInterest + /// 统一按"绝对金额 × 业务方向"重写。普通利息腿收取为正、支付为负; + /// 预付金腿利息方向与保证金本金方向相反。随后重算 RealizedPnl。 + /// 抽为 public static 纯函数以支持无库单测(见 SwapReportInterestSignNormalizeTest)。 + /// 仅当 InterestDirection > 0 时执行(与原内联逻辑等价)。 + /// + public static void NormalizeInterestSignForReport(eod_swap_position position) + { + if (position.InterestDirection <= 0) return; + + var interestRatio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m; + if (ConsTrade.InterestMarginModels.Contains(position.InterestMode)) + { + interestRatio = -interestRatio; + } + position.TdCloseInterest = Math.Abs(position.TdCloseInterest) * interestRatio; + position.RealizedInterest = Math.Abs(position.RealizedInterest) * interestRatio; + // 兼容修复前已落库的利息腿:当时只累计了明细字段,未同步写入 RealizedPnl。 + position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee; + } + /// /// 获取多空组合 平仓详细 /// @@ -2179,22 +2201,9 @@ namespace YLErp.Modules.SwapModule item.eodPosition.PosiNetFeePrice *= multiplier; item.eodPosition.PosiNetNoFeePrice *= multiplier; item.eodPosition.UnderlyingPrice *= multiplier; - if (item.eodPosition.InterestDirection > 0) - { - // 历史数据的 TdCloseInterest、RealizedInterest 存在两种符号口径, - // 风险报表统一按绝对金额和业务方向还原:普通利息腿收取为正、支付为负, - // 预付金腿的利息方向与保证金本金方向相反。 - var interestRatio = item.eodPosition.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m; - if (ConsTrade.InterestMarginModels.Contains(item.eodPosition.InterestMode)) - { - interestRatio = -interestRatio; - } - item.eodPosition.TdCloseInterest = Math.Abs(item.eodPosition.TdCloseInterest) * interestRatio; - item.eodPosition.RealizedInterest = Math.Abs(item.eodPosition.RealizedInterest) * interestRatio; - // 兼容修复前已落库的利息腿:当时只累计了明细字段,未同步写入 RealizedPnl。 - item.eodPosition.RealizedPnl = item.eodPosition.RealizedInterest - + item.eodPosition.RealizedInterestFee; - } + // 历史数据的 TdCloseInterest、RealizedInterest 存在两种符号口径, + // 风险报表统一按绝对金额和业务方向还原,并重算 RealizedPnl。 + NormalizeInterestSignForReport(item.eodPosition); } return retListResult; @@ -2630,12 +2639,24 @@ namespace YLErp.Modules.SwapModule /// /// 计算预付金利息金额。InterestIncomeSum 已是各腿利息金额, /// 按收取为正、支付为负直接轧差求和,不做本金加权。 + /// 抽为 public static 纯函数以支持无库单测(见 SwapWeightedMarginInterestTest)。 /// - private static decimal CalculateWeightedMarginInterest(IEnumerable margins) + public static decimal CalculateWeightedMarginInterest(IEnumerable margins) { return margins.Sum(x => x.InterestIncomeSum * (x.InterestDirection == (int)SwapDirectionEnum.收取 ? 1 : -1)); } + + /// + /// 固定利息腿的累计已实现盈亏 = 累计已实现利息 + 累计已实现利息费用。 + /// 4 处 SaveAutoEodInterestPosition/SaveEodInterestPosition 路径口径一致, + /// 抽为 public static 纯函数以支持无库单测(见 SwapFixedLegRealizedPnlTest), + /// 并消除复制粘贴带来的笔误风险(如 L1296 历史双分号)。 + /// + public static void SetFixedLegRealizedPnl(eod_swap_position position) + { + position.RealizedPnl = position.RealizedInterest + position.RealizedInterestFee; + } /// /// 将数据库中以公司/交易簿记方向保存的日终字段转换为客户视角。 /// 该转换必须在拆分浮动收益、费用和期间付息/分红之前完成,