diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index 861aa11e..f0789b4d 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -401,10 +401,7 @@ namespace YLErp.Modules.SwapModule PositionType = 0, EventType = (int)SwapEventTypeEnum.平仓, EventDate = new DateTime(2026, 7, 9), - // UnwindDate 必须显式设置:ResolveInterestLegPositionsAsOf 自 a1cdb2cd 起按 UnwindDate - // (经济生效日)分桶,而非 EventDate(簿记日)。生产平仓事件总会设 UnwindDate - // (InitUnwind:327、AuotoSwapUnwind:1590)。此处同日场景:UnwindDate == EventDate。 - UnwindDate = new DateTime(2026, 7, 9), + UnwindDate = new DateTime(2026, 7, 10), InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipal = 3000m }; @@ -414,7 +411,7 @@ namespace YLErp.Modules.SwapModule PositionType = 1, EventType = (int)SwapEventTypeEnum.平仓, EventDate = new DateTime(2026, 7, 9), - UnwindDate = new DateTime(2026, 7, 9), + UnwindDate = new DateTime(2026, 7, 10), TradingAmount = 3000000m }; var originalWithFloat = new List @@ -437,17 +434,15 @@ namespace YLErp.Modules.SwapModule } /// - /// [SPC_008] EventDate ≠ UnwindDate 时,ResolveInterestLegPositionsAsOf 按 UnwindDate(经济生效日)分桶。 + /// [SPC_008] EventDate ≠ UnwindDate 时,ResolveInterestLegPositionsAsOf 按 EventDate(事件日期)分桶。 /// ---------------------------------------------------------------------------- - /// 锁定 a1cdb2cd 的修复价值:平仓事件的簿记日(EventDate)可能滞后于经济生效日(UnwindDate) - /// (如 T+N 结算、手动补录)。重放预付金腿 as-of 本金时,分桶必须按 UnwindDate: - /// - settleDate < UnwindDate → 平仓"未发生",as-of=原始本金 - /// - settleDate >= UnwindDate → 平仓"已生效",as-of=实时剩余本金 - /// 修复前按 EventDate 分桶:settleDate 落在 [UnwindDate, EventDate) 区间时,会被误判为"未发生"。 - /// 本测试构造 EventDate=7/11、UnwindDate=7/9,验证 settleDate=7/10 时已按 UnwindDate 生效。 + /// 锁定事件日期作为历史重放的生效边界: + /// - settleDate < EventDate → 平仓"未发生",as-of=原始本金 + /// - settleDate >= EventDate → 平仓"已生效",as-of=实时剩余本金 + /// 本测试构造 EventDate=7/9、UnwindDate=7/10,验证 settleDate=7/9 时已按 EventDate 生效。 /// [TestMethod] - public void SPC_008_EventDateDiffersFromUnwindDate_BucketsByUnwindDate() + public void SPC_008_EventDateDiffersFromUnwindDate_BucketsByEventDate() { const long originalPositionId = 2; var original = new swap_position @@ -463,14 +458,14 @@ namespace YLErp.Modules.SwapModule InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipalFix = 7000m }; - // 关键:EventDate(簿记 7/11) 滞后于 UnwindDate(经济生效 7/9) —— T+N 结算/补录常见 + // 关键:EventDate 为 7/9,as-of 应按事件日期判断。 var close = new swap_flow_event { PositionId = originalPositionId, PositionType = 0, EventType = (int)SwapEventTypeEnum.平仓, - EventDate = new DateTime(2026, 7, 11), - UnwindDate = new DateTime(2026, 7, 9), + EventDate = new DateTime(2026, 7, 9), + UnwindDate = new DateTime(2026, 7, 10), InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipal = 3000m }; @@ -479,8 +474,8 @@ namespace YLErp.Modules.SwapModule PositionId = 1, PositionType = 1, EventType = (int)SwapEventTypeEnum.平仓, - EventDate = new DateTime(2026, 7, 11), - UnwindDate = new DateTime(2026, 7, 9), + EventDate = new DateTime(2026, 7, 9), + UnwindDate = new DateTime(2026, 7, 10), TradingAmount = 3000000m }; var originalWithFloat = new List @@ -490,29 +485,26 @@ namespace YLErp.Modules.SwapModule }; var flows = new[] { close, floatClose }; - // settleDate=7/8(经济生效日前)→ as-of=原始 10000 + // settleDate=7/8(事件日期前)→ as-of=原始 10000 var beforeEffective = SwapDealService.ResolveInterestLegPositionsAsOf( originalWithFloat, new List { realtime }, flows, new DateTime(2026, 7, 8)) .Single(x => x.id == originalPositionId); Assert.AreEqual(10000m, beforeEffective.InterestPrincipalFix, - "7/8(经济生效日前):平仓未发生,as-of 本金应=原始 10000"); + "7/8(事件日期前):平仓未发生,as-of 本金应=原始 10000"); - // settleDate=7/9(经济生效日当天)→ as-of=实时剩余 7000 + // settleDate=7/9(事件日期当天)→ as-of=实时剩余 7000 var onEffectiveDate = SwapDealService.ResolveInterestLegPositionsAsOf( originalWithFloat, new List { realtime }, flows, new DateTime(2026, 7, 9)) .Single(x => x.id == originalPositionId); Assert.AreEqual(7000m, onEffectiveDate.InterestPrincipalFix, - "7/9(经济生效日):平仓已生效,as-of 本金应=实时剩余 7000"); + "7/9(事件日期):平仓已生效,as-of 本金应=实时剩余 7000"); - // 【关键·锁定 a1cdb2cd】settleDate=7/10(生效后、簿记前)→ 应按 UnwindDate 判为已生效 =7000 - // 修复前按 EventDate(7/11) 分桶:7/10 < 7/11 → 误判"未发生" → 返回 10000(错误) - // 修复后按 UnwindDate(7/9) 分桶:7/10 >= 7/9 → 已生效 → 返回 7000(正确) + // settleDate=7/10(事件日期后)→ 仍为实时剩余 7000 var afterEffectiveBeforeBook = SwapDealService.ResolveInterestLegPositionsAsOf( originalWithFloat, new List { realtime }, flows, new DateTime(2026, 7, 10)) .Single(x => x.id == originalPositionId); Assert.AreEqual(7000m, afterEffectiveBeforeBook.InterestPrincipalFix, - "7/10(生效后、簿记前):必须按 UnwindDate 判已生效 → 7000。" + - "若返回 10000,说明 a1cdb2cd 修复被回滚(退回按 EventDate 分桶)。"); + "7/10(事件日期后):必须按 EventDate 判已生效 → 7000。"); } } } diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs index e156f2b9..34509256 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs @@ -217,6 +217,19 @@ namespace YLErp.Modules.SwapModule Console.WriteLine($"UW_006: CloseReCheck={service.CloseReCheckCallCount}次, SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅"); } + [TestMethod] + public void UW_014_事件日期与平仓日期强绑定() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m); + + service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓); + + Assert.AreEqual(unwindData.ValueDate, service.SaveSwapDealCalls[0].data.UnwindDate); + Assert.AreEqual(unwindData.ValueDate, unwindData.UnwindDate); + } + // ================================================================ // 场景7:前端传"占期初(A)"语义,后端入口转"占剩余(B)" —— 全平判定 // 原始名义本金 100M / 剩余 60M,前端传 A=0.6(平掉原始 60M = 剩余全部) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 38eb8886..c4c3a504 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -694,7 +694,7 @@ namespace YLErp.Modules.SwapModule { realPositions ??= new List(); var futureFlows = (completedFlowEvents ?? Enumerable.Empty()) - .Where(x => x.EventType == (int)SwapEventTypeEnum.平仓 && x.UnwindDate > settleDate) + .Where(x => x.EventType == (int)SwapEventTypeEnum.平仓 && x.EventDate > settleDate) .ToList(); var originalNotional = origPositions.Where(x => x.PosiDirection > 0) .Sum(x => x.PosiNotionalValue); @@ -706,10 +706,6 @@ namespace YLErp.Modules.SwapModule || x.InterestMode == (int)InterestModeEnum.追加预付金) .GroupBy(x => x.PositionId) .ToDictionary(x => x.Key, x => x.Sum(v => v.InterestPrincipal)); - var priorClosePositionIds = new HashSet((completedFlowEvents ?? Enumerable.Empty()) - .Where(x => x.EventType == (int)SwapEventTypeEnum.平仓 && x.UnwindDate <= settleDate) - .Select(x => x.PositionId)); - return origPositions.Where(x => x.PosiDirection == 0).Select(p => { if (p.InterestMode == (int)InterestModeEnum.初始预付金 @@ -718,10 +714,6 @@ namespace YLErp.Modules.SwapModule var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id); if (realLeg != null) { - if (!priorClosePositionIds.Contains(p.id)) - { - return p; - } var futurePrincipal = hasNotionalFlows ? p.InterestPrincipalFix * futureCloseNotional / originalNotional : futureClosePrincipal.TryGetValue(p.id, out var flowPrincipal) ? flowPrincipal : 0m; @@ -1505,6 +1497,7 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } + NormalizeEventUnwindDate(unwindData); NormalizeNotionalValues(unwindData); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 @@ -1919,6 +1912,7 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } + NormalizeEventUnwindDate(unwindData); unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓"); var trans = DbContext.Database.BeginTransaction(); @@ -1963,6 +1957,7 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } + NormalizeEventUnwindDate(unwindData); unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.互换, "系统操作_互换"); var trans = DbContext.Database.BeginTransaction(); @@ -2002,7 +1997,7 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } - NormalizeIncomeUnwindDate(unwindData); + NormalizeEventUnwindDate(unwindData); ValidateIncomeValueDate(unwindData, td); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.互换, "系统操作_互换"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 @@ -2043,6 +2038,7 @@ namespace YLErp.Modules.SwapModule throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); + NormalizeEventUnwindDate(swapEvent.unwindData); NormalizeNotionalValues(swapEvent.unwindData); // Stored events keep display ratio A; approval calculations consume remaining ratio B. swapEvent.unwindData.ClosePercent = ToRemainingClosePercent( @@ -2050,6 +2046,11 @@ namespace YLErp.Modules.SwapModule swapEvent.unwindData.NotionalValue, swapEvent.unwindData.PosiNotionalValue); var flowList = FindFlowEventsByEventId(swapEvent.id); + foreach (var item in flowList) + { + item.EventDate = swapEvent.unwindData.ValueDate; + item.UnwindDate = swapEvent.unwindData.UnwindDate; + } swapEvent.unwindData.FlowEvents = flowList; if (eventType == (int)SwapEventTypeEnum.平仓) { @@ -2060,7 +2061,6 @@ namespace YLErp.Modules.SwapModule } if (eventType == (int)SwapEventTypeEnum.互换) { - NormalizeIncomeUnwindDate(swapEvent.unwindData); ValidateIncomeValueDate(swapEvent.unwindData, td); } if (eventType == (int)SwapEventTypeEnum.平仓) @@ -2130,9 +2130,9 @@ namespace YLErp.Modules.SwapModule { throw new ServiceException("未找到交易信息"); } + NormalizeEventUnwindDate(unwindData); if (eventType == (int)SwapEventTypeEnum.互换) { - NormalizeIncomeUnwindDate(unwindData); ValidateIncomeValueDate(unwindData, td); } unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; @@ -2179,7 +2179,7 @@ namespace YLErp.Modules.SwapModule } } - private void NormalizeIncomeUnwindDate(UnwindData unwindData) + private static void NormalizeEventUnwindDate(UnwindData unwindData) { unwindData.UnwindDate = unwindData.ValueDate; } diff --git a/YLErpWeb/Controllers/SwapTrade2Controller.cs b/YLErpWeb/Controllers/SwapTrade2Controller.cs index 699e370a..9fc0b81b 100644 --- a/YLErpWeb/Controllers/SwapTrade2Controller.cs +++ b/YLErpWeb/Controllers/SwapTrade2Controller.cs @@ -396,6 +396,7 @@ namespace YLErp.Web.Controllers /// public JsonResult GetUnwindInterestList(DateTime valueDate,DateTime unwindDate, int tradeId, decimal closePercent, int eventType, decimal notionalValue = 0, decimal posiNotionalValue = 0) { + unwindDate = valueDate; // 前端按"占期初(original)"语义传 closePercent(A);后端 GetUnwindInterests 按"占剩余(remaining)"语义(B)计算。 // 多空互换前端不传 notionalValue/posiNotionalValue(默认 0),则跳过转换保持原行为。 var convertedClosePercent = SwapDealService.ToRemainingClosePercent(closePercent, notionalValue, posiNotionalValue); diff --git a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml index 4d7c979a..5bfd3d91 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml @@ -84,7 +84,7 @@
- +
diff --git a/YLErpWeb/fe-tests/unwindSwapTrade.test.js b/YLErpWeb/fe-tests/unwindSwapTrade.test.js index eb87bc0d..f0774f96 100644 --- a/YLErpWeb/fe-tests/unwindSwapTrade.test.js +++ b/YLErpWeb/fe-tests/unwindSwapTrade.test.js @@ -44,7 +44,14 @@ function loadUnwindHelpers() { vueNumberInput() { return {}; } }, swapPricePrecision: { - createVueInputComponent() { return {}; } + createVueInputComponent() { return {}; }, + getCommonInputFormat() { return {}; }, + normalizeCommon(type, value) { return value; }, + formatCommon(type, value) { return value; }, + getInputFormat(type, field, fallback) { return fallback; }, + roundForSubmit(value) { return value; }, + shiftDecimal(value) { return value; }, + format(value) { return value; } }, tradeHelper: { IsBond() { return false; } }, main: { @@ -94,6 +101,30 @@ describe('unwindSwapTrade 基础费率计算', () => { }); }); +describe('事件日期与平仓日期双向同步', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', 'wwwroot', 'Scripts', 'app', 'swaptrade', 'unwindSwapTrade.js'), + 'utf8' + ); + const viewSource = fs.readFileSync( + path.join(__dirname, '..', 'Views', 'SwapTrade2', 'SwapUnwind.cshtml'), + 'utf8' + ); + + test('事件日期变更时同步平仓日期并触发利息重算', () => { + expect(source).toMatch(/setValueDate\(e\)[\s\S]*deal\.UnwindDate\s*=\s*e[\s\S]*getInterestList\(\)/); + }); + + test('平仓日期可选,变更时同步事件日期', () => { + expect(viewSource).toMatch(/vue-datepicker[^>]*v-model="deal\.UnwindDate"[^>]*v-on:input="setUnwindDate"/); + expect(source).toMatch(/setUnwindDate\(e\)[\s\S]*this\.setValueDate\(e\)/); + }); + + test('预览利息请求使用事件日期作为计算日期', () => { + expect(source).toMatch(/valueDate:\s*thisObj\.deal\.ValueDate[\s\S]*unwindDate:\s*thisObj\.deal\.ValueDate/); + }); +}); + describe('base-rate pending trading fee', () => { const { swapPosiFeeCalc, consPosiFeeType } = loadUnwindHelpers(); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 1c038aef..a7bd0495 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -78,7 +78,7 @@ const vue = new Vue({ created() { this.multiplier = this.deal.StructureType == '普通债券类收益互换' ? 100 : 1; this.initDeal(); - this.setUnwindDate(); + this.setValueDate(this.deal.ValueDate); }, methods: { formatAmount(value) { @@ -168,19 +168,9 @@ const vue = new Vue({ x.InterestClosePnL = formatSwapAmount(x.InterestClosePnL); }); }, - setValueDate(e) {//修改平仓日期 + setValueDate(e) {//修改事件日期,并同步平仓日期 if (e) { this.deal.ValueDate = e; - } - //if (!isUseApproval) { - // this.getInterestList(); - // this.refreshUnderlyingPrice(); - //} else { - // this.dataFormat(); - //} - }, - setUnwindDate(e) {//修改平仓日期 - if (e) { this.deal.UnwindDate = e; this.floatPosition.UnwindDate = e; } @@ -191,6 +181,9 @@ const vue = new Vue({ this.dataFormat(); } }, + setUnwindDate(e) {//修改平仓日期,并同步事件日期 + this.setValueDate(e); + }, changeCloseMethod() {//修改平仓类型 if (this.deal.CloseMethod == 1) { this.deal.ClosePercent = this.oriClosePercent; @@ -372,7 +365,7 @@ const vue = new Vue({ getInterestList() {//根据平仓日期获取利息腿信息 var thisObj = this; // 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 } + var postData = { valueDate: thisObj.deal.ValueDate, unwindDate: thisObj.deal.ValueDate, 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; @@ -422,10 +415,7 @@ const vue = new Vue({ return; } } - if (thisObj.deal.ValueDate > thisObj.deal.UnwindDate) { - main.message("事件日期不能大于平仓日期"); - return; - } + thisObj.deal.UnwindDate = thisObj.deal.ValueDate; let reqObj = _.cloneDeep(thisObj.deal); let marginCloneList = _.cloneDeep(thisObj.marginList); reqObj.FlowEvents = _.cloneDeep(thisObj.interestList);