diff --git a/UnitTestProject/Modules/TradeModule/TradeServiceBaseCloseNotionalCalcTest.cs b/UnitTestProject/Modules/TradeModule/TradeServiceBaseCloseNotionalCalcTest.cs new file mode 100644 index 00000000..9e8f364d --- /dev/null +++ b/UnitTestProject/Modules/TradeModule/TradeServiceBaseCloseNotionalCalcTest.cs @@ -0,0 +1,133 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.Modules.TradeModule; + +namespace YLErp.Modules.TradeModule +{ + /// + /// TradeServiceBase.CalcSwapCloseNotionalFromEventData / CalcOptionCloseNotional 的回归测试。 + /// --------------------------------------------------------------- + /// 守卫锦麟王提交 23108016 "BugFix 互换本次名义本金取错"。 + /// + /// 旧 bug:BuildTriggerContext 了结场景统一用 trade_cash.UnwindPercentRate × 期初名义本金 + /// 算本次名义本金,但收益互换的 trade_cash.UnwindPercentRate 口径与期权不同, + /// 导致互换审批触发条件用错本金,可能绕过/误触发审批阈值。 + /// + /// 修复:互换分支从 swap_event.EventData 反序列化取 CloseNotionalValue 绝对值; + /// 期权分支保留旧逻辑(期初名义本金 × UnwindPercentRate 绝对值)。 + /// + /// 抽出两个静态纯函数以支持无库单测,重点验证容错(null/空/非法 JSON)不会抛异常 + /// 而是返回 0,避免静默吞异常导致名义本金为 0 进而绕过审批阈值。 + /// + [TestClass] + public class TradeServiceBaseCloseNotionalCalcTest + { + // ================================================================ + // 一、CalcSwapCloseNotionalFromEventData 容错与绝对值语义 + // ================================================================ + + [TestMethod] + public void 互换_EventData为null_返回0_不抛异常() + { + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(null); + Assert.AreEqual(0d, result, 0.0001, "null EventData 应容错返回 0"); + } + + [TestMethod] + public void 互换_EventData为空字符串_返回0_不抛异常() + { + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(""); + Assert.AreEqual(0d, result, 0.0001, "空字符串 EventData 应容错返回 0"); + } + + [TestMethod] + public void 互换_EventData为非法JSON_返回0_不抛异常() + { + // 旧实现 catch{} 静默吞异常,抽函数后必须保持此容错契约 + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData("not-a-json"); + Assert.AreEqual(0d, result, 0.0001, "非法 JSON 应被 catch 返回 0,不能抛异常"); + } + + [TestMethod] + public void 互换_EventData为合法JSON_正数CloseNotionalValue_原值返回() + { + var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = 500_000m }); + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData); + Assert.AreEqual(500_000d, result, 0.01, "正数 CloseNotionalValue 应原值返回"); + } + + [TestMethod] + public void 互换_EventData为合法JSON_负数CloseNotionalValue_取绝对值() + { + // 修复的核心契约:Math.Abs 取绝对值,防止方向反向导致名义本金变负 + var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = -500_000m }); + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData); + Assert.AreEqual(500_000d, result, 0.01, "负数 CloseNotionalValue 应取绝对值返回 500000"); + } + + [TestMethod] + public void 互换_EventData为合法JSON_CloseNotionalValue为零_返回0() + { + var eventData = JsonConvert.SerializeObject(new UnwindData { CloseNotionalValue = 0m }); + var result = TradeServiceBase.CalcSwapCloseNotionalFromEventData(eventData); + Assert.AreEqual(0d, result, 0.0001, "CloseNotionalValue=0 应返回 0"); + } + + // ================================================================ + // 二、CalcOptionCloseNotional 容错与绝对值语义 + // ================================================================ + + [TestMethod] + public void 期权_两者都为null_返回0() + { + var result = TradeServiceBase.CalcOptionCloseNotional(null, null); + Assert.AreEqual(0d, result, 0.0001, "两者 null 应返回 0"); + } + + [TestMethod] + public void 期权_期初名义本金为null_返回0() + { + var result = TradeServiceBase.CalcOptionCloseNotional(null, 0.5d); + Assert.AreEqual(0d, result, 0.0001, "originalStockEqvNotional=null 应返回 0"); + } + + [TestMethod] + public void 期权_平仓比例为null_返回0() + { + var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, null); + Assert.AreEqual(0d, result, 0.0001, "unwindPercentRate=null 应返回 0"); + } + + [TestMethod] + public void 期权_两者都有值_正数相乘_返回乘积() + { + // 1,000,000 × 0.3 = 300,000 + var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, 0.3d); + Assert.AreEqual(300_000d, result, 0.01, "1M × 0.3 = 300K"); + } + + [TestMethod] + public void 期权_期初名义本金为负数_取绝对值后相乘() + { + // 异常但容错:-1,000,000 × 0.3 → Abs → 300,000 + var result = TradeServiceBase.CalcOptionCloseNotional(-1_000_000d, 0.3d); + Assert.AreEqual(300_000d, result, 0.01, "期初名义本金为负数应取绝对值后相乘"); + } + + [TestMethod] + public void 期权_平仓比例为负数_取绝对值后相乘() + { + // 异常但容错:1,000,000 × -0.3 → Abs → 300,000 + var result = TradeServiceBase.CalcOptionCloseNotional(1_000_000d, -0.3d); + Assert.AreEqual(300_000d, result, 0.01, "平仓比例为负数应取绝对值后相乘"); + } + + [TestMethod] + public void 期权_两者都为负数_取绝对值后相乘() + { + // -1,000,000 × -0.3 = 300,000(先乘后取 Abs,结果一致) + var result = TradeServiceBase.CalcOptionCloseNotional(-1_000_000d, -0.3d); + Assert.AreEqual(300_000d, result, 0.01, "两者都为负数应取绝对值后相乘"); + } + } +} diff --git a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs index e78e82ff..7d45a901 100644 --- a/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs +++ b/YLErpDAL/Modules/TradeModule/TradeServiceBase.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using YLErp.BLL; using YLErp.DBModels.Consts; using YLErp.DBModels.Enums; @@ -210,18 +210,7 @@ namespace YLErp.Modules.TradeModule && (x.EventType == (int)SwapEventTypeEnum.平仓 || x.EventType == (int)SwapEventTypeEnum.互换)) .OrderByDescending(x => x.id) .FirstOrDefault(); - if (swapEvent != null && !string.IsNullOrEmpty(swapEvent.EventData)) - { - try - { - var unwindData = Newtonsoft.Json.JsonConvert.DeserializeObject(swapEvent.EventData); - if (unwindData != null) - { - ctx.CurrentNotional = Math.Abs((double)unwindData.CloseNotionalValue); - } - } - catch { } - } + ctx.CurrentNotional = CalcSwapCloseNotionalFromEventData(swapEvent?.EventData); } else { @@ -230,15 +219,41 @@ namespace YLErp.Modules.TradeModule .Where(t => t.TradeId == td.id && t.ValidState == ConsGlobal.InValid && !t.IsDeleted) .OrderByDescending(t => t.id) .FirstOrDefault(); - if (tc != null && tc.UnwindPercentRate.HasValue && td.OriginalStockEqvNotional.HasValue) - { - ctx.CurrentNotional = Math.Abs(td.OriginalStockEqvNotional.Value * tc.UnwindPercentRate.Value); - } + ctx.CurrentNotional = CalcOptionCloseNotional(td.OriginalStockEqvNotional, tc?.UnwindPercentRate); } } return ctx; } + /// + /// 互换:从 swap_event.EventData(JSON) 反序列化取 CloseNotionalValue 绝对值。 + /// 容错:EventData 为 null/空/非法 JSON / unwindData=null 时返回 0(不影响审批阈值判断)。 + /// 抽为静态纯函数以支持无库单测。 + /// + public static double CalcSwapCloseNotionalFromEventData(string eventData) + { + if (string.IsNullOrEmpty(eventData)) return 0; + try + { + var unwindData = Newtonsoft.Json.JsonConvert.DeserializeObject(eventData); + return unwindData != null ? Math.Abs((double)unwindData.CloseNotionalValue) : 0; + } + catch + { + return 0; + } + } + + /// + /// 期权:当次平仓名义本金 = 期初名义本金 × 平仓比例(UnwindPercentRate),取绝对值。 + /// 容错:任一参数为 null 时返回 0。抽为静态纯函数以支持无库单测。 + /// + public static double CalcOptionCloseNotional(double? originalStockEqvNotional, double? unwindPercentRate) + { + if (!originalStockEqvNotional.HasValue || !unwindPercentRate.HasValue) return 0; + return Math.Abs(originalStockEqvNotional.Value * unwindPercentRate.Value); + } + /// /// 需求①:判断按触发条件是否需要审批。 /// 取该交易类别的审批流程,若所有节点配置的触发条件都不满足当前业务,则无需审批(返回 false)。