diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs index 103b65f5..7d1dfc27 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs @@ -187,5 +187,55 @@ namespace YLErp.Modules.SwapModule "SwapRealizedPnL 应=SwapCloseAmount(6000)"); Console.WriteLine($"UW_006: CloseReCheck={service.CloseReCheckCallCount}次, SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅"); } + + // ================================================================ + // 场景7:前端传"占期初(A)"语义,后端入口转"占剩余(B)" —— 全平判定 + // 原始名义本金 100M / 剩余 60M,前端传 A=0.6(平掉原始 60M = 剩余全部) + // B = A × Notional/Posi = 0.6 × 100/60 = 1.0 → 触发全平 + // ================================================================ + [TestMethod] + public void UW_007_SwapUnwind_占期初A转占剩余B_全平判定正确() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 0.6m, + closeQty: 600000m, closeNotionalValue: 600000m, positionQty: 600000m); + unwindData.NotionalValue = 1000000m; // 期初名义本金 + unwindData.PosiNotionalValue = 600000m; // 剩余名义本金 + + service.SwapUnwind(unwindData); + + // 桩 SaveSwapDeal 收集的是转换后的 B(落库 A 还原在生产 SaveSwapDealInternal 中,桩跳过) + Assert.AreEqual(1.0m, service.SaveSwapDealCalls[0].data.ClosePercent, 0.0001m, + "入口 A=0.6 应转为 B=1.0(占剩余全平)"); + Assert.AreEqual("已平仓", td.TradeStatus, "B==1 触发全平 TradeStatus=已平仓"); + Console.WriteLine($"UW_007: A=0.6→B={service.SaveSwapDealCalls[0].data.ClosePercent}, TradeStatus={td.TradeStatus} ✅"); + } + + // ================================================================ + // 场景8:占期初(A)转占剩余(B) —— 部分平仓 + // 原始 100M / 剩余 60M,前端传 A=0.3(平掉原始 30M = 剩余的 50%) + // B = A × Notional/Posi = 0.3 × 100/60 = 0.5 → 部分平仓 + // ================================================================ + [TestMethod] + public void UW_008_SwapUnwind_占期初A转占剩余B_部分平仓正确() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.3m, + closeQty: 300000m, closeNotionalValue: 300000m, positionQty: 600000m); + unwindData.NotionalValue = 1000000m; // 期初名义本金 + unwindData.PosiNotionalValue = 600000m; // 剩余名义本金 + + service.SwapUnwind(unwindData); + + Assert.AreEqual(0.5m, service.SaveSwapDealCalls[0].data.ClosePercent, 0.0001m, + "入口 A=0.3 应转为 B=0.5(占剩余 50%)"); + Assert.AreEqual(1, td.HasPartialUnWind, "B≠1 应为部分平仓,设 HasPartialUnWind=1"); + Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变"); + Console.WriteLine($"UW_008: A=0.3→B={service.SaveSwapDealCalls[0].data.ClosePercent}, HasPartialUnWind={td.HasPartialUnWind} ✅"); + } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 26285156..246142bd 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -726,6 +726,30 @@ namespace YLErp.Modules.SwapModule return (closePrincipal, posiPrincipal, newClosePercent); } + /// + /// 平仓比例口径转换(解决"显示占期初 / 计算占剩余"双语义问题)。 + /// 前端与事件列表展示用"占期初(original)"语义(A);后端 CalcNotionalByMode / 费用递减 / + /// 全平判定均按"占剩余(remaining)"语义(B)消费。 + /// A → B:B = A × 期初名义本金(NotionalValue) / 剩余名义本金(PosiNotionalValue),并 cap 到 1。 + /// B → A:A = B × 剩余名义本金 / 期初名义本金。 + /// 分母为 0(无持仓等异常场景)时原样返回,避免除零。 + /// + public static decimal ToRemainingClosePercent(decimal originalClosePercent, decimal notionalValue, decimal posiNotionalValue) + { + if (posiNotionalValue <= 0) return originalClosePercent; + var remaining = originalClosePercent * notionalValue / posiNotionalValue; + return remaining > 1 ? 1 : remaining; + } + + /// + /// B(占剩余) → A(占期初),用于落库 / 事件列表展示还原。见 ToRemainingClosePercent。 + /// + public static decimal ToOriginalClosePercent(decimal remainingClosePercent, decimal notionalValue, decimal posiNotionalValue) + { + if (notionalValue <= 0) return remainingClosePercent; + return remainingClosePercent * posiNotionalValue / notionalValue; + } + /// /// 获取固定利率 /// @@ -1226,6 +1250,9 @@ namespace YLErp.Modules.SwapModule } //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易 + // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 + // 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。 + unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); bool cofirm = false; ExecuteInTransaction(() => { @@ -1843,7 +1870,13 @@ namespace YLErp.Modules.SwapModule } var flowList = new List(unwindData.FlowEvents); unwindData.FlowEvents.Clear(); + // 落库展示用"占期初(original)"语义(A);计算链(费用递减/全平判定)用"占剩余(remaining)"语义(B)。 + // 序列化前把 ClosePercent 还原为 A,序列化后立即还原回 B 供后续使用。 + var storedClosePercent = ToOriginalClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); + var incomingClosePercent = unwindData.ClosePercent; + unwindData.ClosePercent = storedClosePercent; string data = JsonConvert.SerializeObject(unwindData); + unwindData.ClosePercent = incomingClosePercent; var swapEvent = new SwapEventService(this).AddSwapEventDate(unwindData.ValueDate, unwindData.SwapTradeId, eventType, data, clientCashId, true, eventResason);//将平仓、互换总额存入事件 foreach (var item in flowList) { diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 52d4ae46..717ad13f 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -283,9 +283,12 @@ namespace YLErp.Web.Controllers /// /// /// - public JsonResult GetUnwindInterestList(DateTime valueDate,DateTime unwindDate, int tradeId, decimal closePercent, int eventType) + public JsonResult GetUnwindInterestList(DateTime valueDate,DateTime unwindDate, int tradeId, decimal closePercent, int eventType, decimal notionalValue = 0, decimal posiNotionalValue = 0) { - var interests = new SwapDealService(CurUser).GetUnwindInterests(valueDate, unwindDate, tradeId, closePercent, eventType); + // 前端按"占期初(original)"语义传 closePercent(A);后端 GetUnwindInterests 按"占剩余(remaining)"语义(B)计算。 + // 多空互换前端不传 notionalValue/posiNotionalValue(默认 0),则跳过转换保持原行为。 + var convertedClosePercent = SwapDealService.ToRemainingClosePercent(closePercent, notionalValue, posiNotionalValue); + var interests = new SwapDealService(CurUser).GetUnwindInterests(valueDate, unwindDate, tradeId, convertedClosePercent, eventType); foreach (var interest in interests) { interest.TdInterestAmount=Math.Round(interest.TdInterestAmount, ConsGlobal.MoneyRound,MidpointRounding.AwayFromZero); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index e1fce1dc..03f90bc7 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -17,8 +17,8 @@ const vue = new Vue({ marginList: [], initPosiNetPrice: 0, multiplier: 1, - // 多次部分平仓后,ClosePercent 语义为“占剩余持仓的比例”(后端 GetUnwindInterests 用 remainingBase×closePercent 计算预付金返还), - // 故最多可平 100% 剩余,oriClosePercent 恒为 1;不能用 model.ClosePercent(=剩余/原始,Definition A 旧口径),否则全部平仓/按比例会少返预付金 + // 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。 + // oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。 oriClosePercent: 1, ratio: 1, shortRatio: 1, @@ -55,12 +55,9 @@ const vue = new Vue({ this.ratio = this.floatPosition.PayDirection == 1 ? -1 : 1; this.shortRatio = this.floatPosition.PositionType == 1 ? 1 : -1; this.TradeStartDate = model.TradeStartDate; - // 多次部分平仓后 ClosePercent 语义为"占剩余持仓比例"。 - // 仅当"全部平仓"(CloseMethod==1) 时修正旧口径(model.ClosePercent 可能=剩余/原始<1)为 1; - // "部分平仓"(CloseMethod==2) 时保留已提交比例(平仓待复核场景),避免覆盖用户已提交的 closePercent - if (this.deal.CloseMethod === 1) { - this.deal.ClosePercent = 1; - } + // 最多可平比例(占期初口径) = 剩余名义本金 / 期初名义本金;分母为 0 时兜底为 1 + this.oriClosePercent = (this.deal.NotionalValue && this.deal.PosiNotionalValue) + ? this.deal.PosiNotionalValue / this.deal.NotionalValue : 1; // 转换期末标的价格为百分比形式 if (this.floatPosition.TradingAmountAvg) { this.floatPosition.TradingAmountAvg = this.floatPosition.TradingAmountAvg * this.multiplier; @@ -154,8 +151,8 @@ const vue = new Vue({ } else { this.deal.CloseMethod = 2; } - // 多次部分平仓后 PosiNotionalValue 才是剩余本金,不能用原始 NotionalValue,否则平仓名义本金偏大 - this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.PosiNotionalValue)); + // 占期初口径:平仓名义本金 = 平仓比例 × 期初名义本金(NotionalValue) + this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue)); this.calcTradingFeePending(); this.getInterestList(); this.calcFloatClosePnl(); @@ -167,8 +164,8 @@ const vue = new Vue({ return; } this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent)); - // 多次部分平仓后 PosiNotionalValue 才是剩余本金,不能用原始 NotionalValue,否则平仓名义本金偏大 - this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.PosiNotionalValue)); + // 占期初口径:平仓名义本金 = 平仓比例 × 期初名义本金(NotionalValue) + this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue)); if (parseFloat(this.deal.CloseNotionalValue) == parseFloat(this.deal.PosiNotionalValue)) { this.floatPosition.CloseMethod = 1; } else { @@ -184,8 +181,8 @@ const vue = new Vue({ this.deal.CloseNotionalValue = this.deal.PosiNotionalValue; return; } - // 多次部分平仓后应以 PosiNotionalValue(剩余) 为分母,否则 ClosePercent 偏小,导致后端预付金返还本金计算错误 - this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseNotionalValue) / parseFloat(this.deal.PosiNotionalValue)); + // 占期初口径:平仓比例 = 平仓名义本金 / 期初名义本金(NotionalValue) + this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseNotionalValue) / parseFloat(this.deal.NotionalValue)); this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent)); this.calcTradingFeePending(); this.getInterestList(); @@ -272,7 +269,8 @@ const vue = new Vue({ }, getInterestList() {//根据平仓日期获取利息腿信息 var thisObj = this; - var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.UnwindDate, tradeId: thisObj.deal.SwapTradeId, closePercent: thisObj.deal.ClosePercent, eventType: 2 } + // closePercent 按"占期初(original)"语义(A)传给后端,由 GetUnwindInterestList 转为"占剩余(B)"计算 + var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.UnwindDate, tradeId: thisObj.deal.SwapTradeId, closePercent: thisObj.deal.ClosePercent, eventType: 2, notionalValue: thisObj.deal.NotionalValue, posiNotionalValue: thisObj.deal.PosiNotionalValue } main.post("/swaptrade2/GetUnwindInterestList", postData, { async: true }).done(function (resp) { thisObj.interestList = resp.obj.filter((item) => { return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9;