diff --git a/UnitTestProject/Modules/SwapModule/SwapEodRealizedPnlCalcTest.cs b/UnitTestProject/Modules/SwapModule/SwapEodRealizedPnlCalcTest.cs new file mode 100644 index 00000000..4be39977 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapEodRealizedPnlCalcTest.cs @@ -0,0 +1,206 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapEodPositionService.CalculateSwapRealizedPnl 的回归测试。 + /// --------------------------------------------------------------- + /// 守卫张名锐提交 6676b625 "fix(swap): 修正掉期产品保证金利息计算逻辑"。 + /// + /// 旧 bug:eod_swap.RealizedPnL 直接 Sum(s.RealizedPnl),未对保证金腿利息做方向反向, + /// 导致"收取对手方保证金"产生的利息被错误计入我方收益(实际是我方支付给对手方的成本), + /// 框架合约已实现收益虚高。 + /// + /// 修复:新增 CalculateSwapRealizedPnl —— + /// 非保证金腿:interestRatio = Direction==收取 ? 1 : -1(维持数据库方向) + /// 保证金腿(初始预付金 5 / 追加预付金 6):interestRatio 反向 + /// 最终:RealizedInterest × interestRatio + 其他 4 字段 + /// + /// 抽为 public static 纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。 + /// 本测试直接锁定方向反向契约,防止后续误改回归。 + /// + [TestClass] + public class SwapEodRealizedPnlCalcTest + { + // ================================================================ + // 场景1:非保证金腿收取方向 → RealizedInterest × +1(维持原向) + // ================================================================ + [TestMethod] + public void 非保证金腿_收取方向_利息维持原向系数为1() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.收取, + realizedInterest: 1000m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(1000m, result, 0.0001m, + "非保证金腿收取方向:利息 ×(+1)=1000"); + } + + // ================================================================ + // 场景2:非保证金腿支付方向 → RealizedInterest × -1(维持原向) + // ================================================================ + [TestMethod] + public void 非保证金腿_支付方向_利息维持原向系数为负1() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.支付, + realizedInterest: 1000m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(-1000m, result, 0.0001m, + "非保证金腿支付方向:利息 ×(-1)=-1000"); + } + + // ================================================================ + // 场景3:保证金腿(初始预付金)收取方向 → 利息反向,系数 -1 + // 这是 6676b625 修复的核心场景:收取对手方保证金产生的利息是我方支付成本 + // ================================================================ + [TestMethod] + public void 保证金腿_初始预付金_收取方向_利息反向系数为负1() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.收取, + realizedInterest: 1000m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(-1000m, result, 0.0001m, + "保证金腿收取方向:利息应反向 ×(-1)=-1000(修复前会错误得 +1000)"); + } + + // ================================================================ + // 场景4:保证金腿(初始预付金)支付方向 → 利息反向,系数 +1 + // ================================================================ + [TestMethod] + public void 保证金腿_初始预付金_支付方向_利息反向系数为1() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.支付, + realizedInterest: 1000m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(1000m, result, 0.0001m, + "保证金腿支付方向:利息应反向 ×(+1)=1000"); + } + + // ================================================================ + // 场景5:追加预付金同初始预付金,同样走反向逻辑 + // ================================================================ + [TestMethod] + public void 保证金腿_追加预付金_收取方向_利息反向() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.追加预付金, + interestDirection: (int)SwapDirectionEnum.收取, + realizedInterest: 500m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + Assert.AreEqual(-500m, result, 0.0001m, + "追加预付金(mode=6)与初始预付金(mode=5)同走反向逻辑"); + } + + // ================================================================ + // 场景6:完整 5 字段汇总(MtmPnL + Dividend + Fee + Interest×ratio + InterestFee) + // 保证金腿收取方向,Interest=200, 其他各 100 + // 期望:100 + 100 + 100 + 200×(-1) + 100 = 200 + // ================================================================ + [TestMethod] + public void 完整5字段汇总_保证金腿收取方向_利息反向后合计正确() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.收取, + realizedMtmPnL: 100m, + realizedDividend: 100m, + realizedFee: 100m, + realizedInterest: 200m, + realizedInterestFee: 100m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + // 100 + 100 + 100 + 200×(-1) + 100 = 200 + Assert.AreEqual(200m, result, 0.0001m, + "5 字段汇总:保证金腿收取方向,Interest×(-1) 后合计=200,验证所有字段都参与计算"); + } + + // ================================================================ + // 场景7:完整 5 字段汇总(非保证金腿收取方向) + // 非保证金腿收取方向,Interest=200, 其他各 100 + // 期望:100 + 100 + 100 + 200×(+1) + 100 = 600 + // ================================================================ + [TestMethod] + public void 完整5字段汇总_非保证金腿收取方向_利息原向合计正确() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.固定值, + interestDirection: (int)SwapDirectionEnum.收取, + realizedMtmPnL: 100m, + realizedDividend: 100m, + realizedFee: 100m, + realizedInterest: 200m, + realizedInterestFee: 100m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + // 100 + 100 + 100 + 200×(+1) + 100 = 600 + Assert.AreEqual(600m, result, 0.0001m, + "5 字段汇总:非保证金腿收取方向,Interest×(+1) 后合计=600"); + } + + // ================================================================ + // 场景8:RealizedInterest=0 边界 —— 方向反向无影响,结果为其他 4 字段之和 + // ================================================================ + [TestMethod] + public void 利息为零_方向反向无影响_结果为其他4字段之和() + { + var pos = NewPosition( + interestMode: (int)InterestModeEnum.初始预付金, + interestDirection: (int)SwapDirectionEnum.收取, + realizedMtmPnL: 100m, + realizedDividend: 50m, + realizedFee: 30m, + realizedInterest: 0m, + realizedInterestFee: 20m); + + var result = SwapEodPositionService.CalculateSwapRealizedPnl(pos); + + // 100 + 50 + 30 + 0×(-1) + 20 = 200 + Assert.AreEqual(200m, result, 0.0001m, + "RealizedInterest=0 时方向反向无影响,结果为其他 4 字段之和"); + } + + // ================================================================ + // Helper:构造 eod_swap_position(只设置参与计算的 7 个字段) + // ================================================================ + private static eod_swap_position NewPosition( + int interestMode, + int interestDirection, + decimal realizedMtmPnL = 0m, + decimal realizedDividend = 0m, + decimal realizedFee = 0m, + decimal realizedInterest = 0m, + decimal realizedInterestFee = 0m) + { + return new eod_swap_position + { + InterestMode = interestMode, + InterestDirection = interestDirection, + RealizedMtmPnL = realizedMtmPnL, + RealizedDividend = realizedDividend, + RealizedFee = realizedFee, + RealizedInterest = realizedInterest, + RealizedInterestFee = realizedInterestFee + }; + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index ce8e0eda..0c44f02a 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -2058,11 +2058,12 @@ namespace YLErp.Modules.SwapModule /// 浮动腿及普通利息腿维持数据库记录的方向;初始/追加预付金腿的利息 /// 则与保证金本金方向相反。这样“收取对手方保证金”产生的利息会作为 /// 我方支付给对手方的成本计入,而不会错误增加框架合约已实现收益。 + /// 抽为静态纯函数以支持无库单测(marginTypes 等价于 ConsTrade.InterestMarginModels)。 /// - private decimal CalculateSwapRealizedPnl(eod_swap_position position) + public static decimal CalculateSwapRealizedPnl(eod_swap_position position) { var interestRatio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m; - if (marginTypes.Contains(position.InterestMode)) + if (ConsTrade.InterestMarginModels.Contains(position.InterestMode)) { interestRatio = -interestRatio; }