using YLErp.DBModels; using YLErp.DBModels.Enums; using YLErp.Helpers; namespace YLErp.Modules.SwapModule { /// /// SwapFrontendPnlValidator.BuildFrontendValidationDiffs 的回归测试。 /// ----------------------------------------------------------------- /// 守卫提交 41553970 "fix(swap): 修复债券结息价差盈亏计算逻辑"。 /// /// 背景:ValidateFrontendPnL 用 FrontendCalcReference 重算盈亏与前端值比对, /// 原为 private void + 吞异常,无法单测。拆出 BuildFrontendValidationDiffs 纯函数: /// - 入参:UnwindData + isIncome /// - 返回:null(前置条件不满足)或 List<FrontendPnlDiff>(超阈值的差异项) /// - 副作用:无(日志留在 ValidateFrontendPnL 外层) /// /// 本测试锁定: /// 1) 前端值与后端重算一致 → 返回空列表 /// 2) 前端值与后端重算不一致 → 返回对应字段差异 /// 3) 无浮动腿 → 返回 null /// 4) PosiGrossPrice=0 → 返回 null /// 5) PositionQty/CloseQty 口径(41553970 修复点)正确传入 /// [TestClass] public class SwapFrontendPnlValidateTest { // ================================================================ // 场景1:前端值与后端重算一致 → 返回空列表 // 用 FC_001 同款输入:债券多头,PosiGrossPrice=1.02, TradingAmountAvg=105, // CloseQty=1000, PayDirection=1, PositionType=1, TradingFee="20" // 后端重算:MarkClosePnl=30, FloatPnlSum=50, SwapRealizedPnL=50, SwapCloseAmount=50 // 前端也填这些值 → 无差异 // ================================================================ [TestMethod] public void 前后端一致_返回空差异列表() { var unwindData = BuildBaseUnwindData( posiGrossPrice: 1.02m, tradingAmountAvg: 105m, closeQty: 1000, positionQty: 1000, tradingFee: "20", tradingFeePending: "0", dividendIn: "0", payDirection: 1, positionType: 1, swapRealizedPnL: 50m, swapCloseAmount: 50m, markClosePnl: 30m); var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false); Assert.IsNotNull(diffs, "前置条件满足应返回列表而非 null"); Assert.AreEqual(0, diffs.Count, $"前后端一致应无差异,实际 {diffs.Count} 条:{string.Join(",", diffs.Select(d => d.Field))}"); } // ================================================================ // 场景2:SwapRealizedPnL 前端填错 → 返回该字段差异 // 后端重算 SwapRealizedPnL=50, SwapCloseAmount=50; // 前端 SwapRealizedPnL 故意填 60(SwapCloseAmount 保持 50 一致)→ 只 SwapRealizedPnL 有差异 // ================================================================ [TestMethod] public void SwapRealizedPnL前端填错_返回该字段差异() { var unwindData = BuildBaseUnwindData( posiGrossPrice: 1.02m, tradingAmountAvg: 105m, closeQty: 1000, positionQty: 1000, tradingFee: "20", tradingFeePending: "0", dividendIn: "0", payDirection: 1, positionType: 1, swapRealizedPnL: 60m, // 故意填错(正确=50) swapCloseAmount: 50m, // 保持一致 markClosePnl: 30m); // 保持一致 var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false); Assert.IsNotNull(diffs); CollectionAssert.AreEquivalent( new[] { "SwapRealizedPnL" }, diffs.Select(d => d.Field).ToArray(), "应只捕获 SwapRealizedPnL 的差异"); var diff = diffs.Single(d => d.Field == "SwapRealizedPnL"); Assert.AreEqual(60m, diff.FrontendValue, "前端值=60"); Assert.AreEqual(50m, diff.BackendValue, 0.01m, "后端重算=50"); Assert.AreEqual(10m, diff.Delta, 0.01m, "Delta=10"); } // ================================================================ // 场景3:MarkClosePnl 前端填错 → 返回该字段差异 // 后端重算 MarkClosePnl=30;前端故意填 25(其他保持一致)→ 只 MarkClosePnl 有差异 // ================================================================ [TestMethod] public void MarkClosePnl前端填错_返回该字段差异() { var unwindData = BuildBaseUnwindData( posiGrossPrice: 1.02m, tradingAmountAvg: 105m, closeQty: 1000, positionQty: 1000, tradingFee: "20", tradingFeePending: "0", dividendIn: "0", payDirection: 1, positionType: 1, swapRealizedPnL: 50m, swapCloseAmount: 50m, markClosePnl: 25m); // 故意填错(正确=30) var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false); Assert.IsNotNull(diffs); CollectionAssert.AreEquivalent( new[] { "MarkClosePnl" }, diffs.Select(d => d.Field).ToArray(), "应只捕获 MarkClosePnl 的差异"); var diff = diffs.Single(d => d.Field == "MarkClosePnl"); Assert.AreEqual(25m, diff.FrontendValue); Assert.AreEqual(30m, diff.BackendValue, 0.01m); Assert.AreEqual(-5m, diff.Delta, 0.01m); } // ================================================================ // 场景4:无浮动腿(FlowEvents 为空)→ 返回 null // ================================================================ [TestMethod] public void 无浮动腿_返回null() { var unwindData = new UnwindData { SwapTradeId = 1, CloseQty = 1000, PositionQty = 1000, FlowEvents = new List() // 完全空 }; var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false); Assert.IsNull(diffs, "无浮动腿应返回 null(跳过校验)"); } // ================================================================ // 场景5:浮动腿 PosiGrossPrice=0 → 返回 null(避免误报) // ================================================================ [TestMethod] public void 浮动腿PosiGrossPrice为零_返回null() { var unwindData = BuildBaseUnwindData( posiGrossPrice: 0m, // 前端未传 → 0 tradingAmountAvg: 105m, closeQty: 1000, positionQty: 1000, tradingFee: "20", tradingFeePending: "0", dividendIn: "0", payDirection: 1, positionType: 1, swapRealizedPnL: 50m, swapCloseAmount: 0m, markClosePnl: 30m); var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false); Assert.IsNull(diffs, "PosiGrossPrice=0 应返回 null(避免误报)"); } // ================================================================ // 场景6:41553970 修复点 —— PositionQty 必须正确传入后端重算 // 旧 bug:PositionQty 未传入,导致部分平仓时盈亏口径错误。 // 验证:PositionQty != CloseQty 时,后端重算仍按真实 PositionQty 走 // (本场景构造部分平仓:CloseQty=500, PositionQty=1000) // 平仓页 unwind 用 CloseQty 算 MarkClosePnl: // MarkClosePnl = 500×(1.05−1.02)×1×1 = 15 // FloatPnlSum = 15 + 20 + 0 + 0 = 35 // SwapRealizedPnL = SwapCloseAmount = 35 // ================================================================ [TestMethod] public void 部分平仓_PositionQty正确传入后端重算() { var unwindData = BuildBaseUnwindData( posiGrossPrice: 1.02m, tradingAmountAvg: 105m, closeQty: 500, positionQty: 1000, tradingFee: "20", tradingFeePending: "0", dividendIn: "0", payDirection: 1, positionType: 1, swapRealizedPnL: 35m, // 与后端重算一致 swapCloseAmount: 35m, // 与后端重算一致 markClosePnl: 15m); // 与后端重算一致 var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false); Assert.IsNotNull(diffs); Assert.AreEqual(0, diffs.Count, $"部分平仓 PositionQty 正确传入应无差异,实际 {diffs.Count} 条:" + string.Join(",", diffs.Select(d => $"{d.Field}(fe={d.FrontendValue},be={d.BackendValue})"))); } // ================================================================ // 场景7:阈值边界 —— 差异恰好等于阈值(0.01)不报,超过才报 // 后端重算 SwapRealizedPnL=50, SwapCloseAmount=50; // 前端 SwapRealizedPnL 填 50.01 → 差异 0.01 不> 0.01 → 不报 // 前端 SwapRealizedPnL 填 50.02 → 差异 0.02 > 0.01 → 报 // (SwapCloseAmount 保持 50 一致,不参与本场景断言) // ================================================================ [TestMethod] public void 阈值边界_差异等于阈值不报_超过才报() { // 差异 = 0.01,不 > 0.01,不报 var unwindDataEq = BuildBaseUnwindData( posiGrossPrice: 1.02m, tradingAmountAvg: 105m, closeQty: 1000, positionQty: 1000, tradingFee: "20", tradingFeePending: "0", dividendIn: "0", payDirection: 1, positionType: 1, swapRealizedPnL: 50.01m, swapCloseAmount: 50m, markClosePnl: 30m); var diffsEq = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindDataEq, isIncome: false); Assert.IsNotNull(diffsEq); Assert.IsFalse(diffsEq.Any(d => d.Field == "SwapRealizedPnL"), "差异=0.01 不> 阈值,不应报 SwapRealizedPnL"); // 差异 = 0.02 > 0.01,报 var unwindDataOver = BuildBaseUnwindData( posiGrossPrice: 1.02m, tradingAmountAvg: 105m, closeQty: 1000, positionQty: 1000, tradingFee: "20", tradingFeePending: "0", dividendIn: "0", payDirection: 1, positionType: 1, swapRealizedPnL: 50.02m, swapCloseAmount: 50m, markClosePnl: 30m); var diffsOver = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindDataOver, isIncome: false); Assert.IsNotNull(diffsOver); Assert.IsTrue(diffsOver.Any(d => d.Field == "SwapRealizedPnL"), "差异=0.02 > 阈值,应报 SwapRealizedPnL"); } // ================================================================ // 场景8:isIncome=true 走 CalcIncome 路径 —— 确保分支选择正确 // 结息页公式与平仓页不同,构造一致场景验证不抛异常且返回列表 // ================================================================ [TestMethod] public void IsIncome为true_走CalcIncome分支_返回列表() { // income 页 MarkClosePnl = PositionQty × ContractSize × (ExitPrice×scale − EntryPrice) × floatRatio // = 1000 × 1 × (105×0.01 − 1.02) × 1 = 30 // FloatPnlSum = 30 + 20 = 50;SwapRealizedPnL = SwapCloseAmount = 50 var unwindData = BuildBaseUnwindData( posiGrossPrice: 1.02m, tradingAmountAvg: 105m, closeQty: 1000, positionQty: 1000, tradingFee: "20", tradingFeePending: "0", dividendIn: "0", payDirection: 1, positionType: 1, swapRealizedPnL: 50m, swapCloseAmount: 50m, markClosePnl: 30m); var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: true); Assert.IsNotNull(diffs, "isIncome=true 也应返回列表(可能为空或有差异)"); // 不锁死具体差异,只验证分支可达、不抛异常 } // ================================================================ // 场景9:自定义阈值 —— threshold=1.0 时小差异不报 // 后端 SwapRealizedPnL=50, SwapCloseAmount=50; // 前端 SwapRealizedPnL=50.5(差异 0.5 < 1.0 不报),SwapCloseAmount=50 一致 // ================================================================ [TestMethod] public void 自定义大阈值_小差异不报() { var unwindData = BuildBaseUnwindData( posiGrossPrice: 1.02m, tradingAmountAvg: 105m, closeQty: 1000, positionQty: 1000, tradingFee: "20", tradingFeePending: "0", dividendIn: "0", payDirection: 1, positionType: 1, swapRealizedPnL: 50.5m, // 差异 0.5 swapCloseAmount: 50m, markClosePnl: 30m); var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false, threshold: 1.0m); Assert.IsNotNull(diffs); Assert.IsFalse(diffs.Any(d => d.Field == "SwapRealizedPnL"), "threshold=1.0 时差异 0.5 不应报"); } // ================================================================ // Helper:构造带一条浮动腿 + 一条利息腿的 UnwindData // 默认用债券(UnderlyingInstrumentType 走 IsBond=true → multiplier=100) // 字段值与 FrontendCalcCharacterizationTest.FC_001 对齐 // ================================================================ private static UnwindData BuildBaseUnwindData( decimal posiGrossPrice, decimal tradingAmountAvg, decimal closeQty, decimal positionQty, string tradingFee, string tradingFeePending, string dividendIn, int payDirection, int positionType, decimal swapRealizedPnL, decimal swapCloseAmount, decimal markClosePnl) { // 浮动腿(债券,有 UnderlyingCode) var floatLeg = new swap_flow_event { UnderlyingCode = "511160.SH", UnderlyingInstrumentType = "Bond", PosiGrossPrice = posiGrossPrice, TradingAmountAvg = tradingAmountAvg, ContractSize = 1m, PayDirection = payDirection, PositionType = positionType, TradingFee = decimal.Parse(tradingFee), TradingFeePending = decimal.Parse(tradingFeePending), DividendIn = decimal.Parse(dividendIn), MarkClosePnl = markClosePnl, InterestMode = (int)InterestModeEnum.标的期初全价 }; // 利息腿(无 UnderlyingCode) var interestLeg = new swap_flow_event { InterestMode = (int)InterestModeEnum.固定值, InterestClosePnL = 0m }; return new UnwindData { SwapTradeId = 1, CloseQty = closeQty, PositionQty = positionQty, CloseNotionalValue = closeQty * 100m, // 债券面值 100 SwapRealizedPnL = swapRealizedPnL, SwapCloseAmount = swapCloseAmount, FlowEvents = new List { floatLeg, interestLeg } }; } } }