From db46e48ec5a7e3c52705a1a481d78f1f66f972c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Wed, 29 Jul 2026 17:37:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(swap):=20=E4=BF=AE=E5=A4=8D=E4=BA=92?= =?UTF-8?q?=E6=8D=A2=E4=BA=A7=E5=93=81=E5=88=B0=E6=9C=9F=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E5=92=8C=E5=88=A9=E6=81=AF=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化到期日最终结算判断逻辑,确保只有完全覆盖才清零待实现余额 - 引入利息金额舍入处理,避免精度差异导致的结算误差 - 完善自动互换流水处理,确保实际结算金额与待实现计算分离 - 修复部分平仓后日终持仓继承逻辑,保持利息累计连续性 - 调整到期自动互换处理,确保最后结算后待实现余额正确归零 - 优化利息收入和费用的独立计算,避免历史数据重复计算问题 --- .../SwapModule/DealInterestsScenarioTest.cs | 202 ++++++++++++++++++ .../SwapModule/SwapEodPositionService.cs | 57 ++++- 2 files changed, 249 insertions(+), 10 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs index 52a1eaac..f1e87b49 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs @@ -42,6 +42,14 @@ namespace YLErp.Modules.SwapModule /// private sealed class StubEodPositionService : TestableSwapEodPositionService { + /// + /// 自动互换场景可注入固定流水,避免为了验证结算边界而依赖真实计息公式。 + /// 未赋值时仍走生产使用的真实 GetInterests 计算。 + /// + public List AutoInterests { get; set; } + + public eod_swap_position LastInterestCalculationEodPosition { get; private set; } + public StubEodPositionService() : base(nameof(DealInterestsScenarioTest)) { } @@ -60,6 +68,12 @@ namespace YLErp.Modules.SwapModule bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { + LastInterestCalculationEodPosition = eodPositions.SingleOrDefault(); + if (AutoInterests != null) + { + return AutoInterests; + } + return new SwapDealService(this).GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, @@ -75,6 +89,28 @@ namespace YLErp.Modules.SwapModule return PersistedPositions.LastOrDefault(); } + // public 包装:验证自动互换时的“高精度应结 -> 两位实际结算 -> 待实现尾差”链路。 + public eod_swap_position ExecuteSaveAutoEodInterestPosition( + eod_swap_position eodPayPosition, swap_position position, trade td, + DateTime valueDate, IntervalModel interval) + { + SaveAutoEodInterestPosition(eodPayPosition, null, position, td, valueDate, interval, + null, DealInterestsScenarioTest.Principal, 0m, 1m, DealInterestsScenarioTest.Principal); + return PersistedPositions.LastOrDefault(); + } + + public eod_swap_position ExecuteSaveAutoEodWithCloseInterestPosition( + eod_swap_position eodPayPosition, swap_position position, trade td, + DateTime valueDate, IntervalModel interval, decimal posiLongNotional, + decimal posiShortNotional, List flowEvents, + decimal closeNotional, bool autoSwap) + { + SaveAutoEodWithCloseInterestPosition(eodPayPosition, null, position, td, valueDate, interval, + posiLongNotional, posiShortNotional, flowEvents, closeNotional, autoSwap, 1m, + DealInterestsScenarioTest.Principal); + return PersistedPositions.LastOrDefault(); + } + // public 包装:直接调用 protected virtual DealInterests(已改为 virtual,无需反射) public void ExecuteDealInterests( List interestList, List eodPositions, @@ -169,6 +205,27 @@ namespace YLErp.Modules.SwapModule }; } + /// + /// 创建自动互换利息流水。InterestAmount 是高精度应结,生产入口负责将实际结算收敛到两位。 + /// + private static swap_flow_event CreateAutoSwapFlowEvent(DateTime eventDate, decimal interestAmount) + { + return new swap_flow_event + { + id = 2002, SwapTradeId = 1, EventType = (int)SwapFlowEventTypeEnum.自动互换, + EventDate = eventDate, UnwindDate = eventDate, PositionId = 1001, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestAmount = interestAmount, + TdInterestAmount = interestAmount, + InterestClosePnL = interestAmount, + InterestRate = FixedRate, + InterestMode = (int)InterestModeEnum.标的期初全价, + InterestPrincipal = Principal, + FloatRate = 0m, + DataState = (int)SwapFlowDateStateEnum.完成 + }; + } + private static void AssertDecimal(decimal expected, decimal actual, string message = "") { var tolerance = 1m / (decimal)Math.Pow(10, ConsGlobal.PriceRound - 2); @@ -566,6 +623,151 @@ namespace YLErp.Modules.SwapModule #endregion + // ================================================================ + // 场景6:自动互换两位实际结算与到期清零 + // ================================================================ + + #region 场景6:自动互换尾差与最终结算 + + /// + /// [DI_AUTO_SETTLEMENT_001] 非最终自动互换:实际结算按两位,尾差继续保留在待实现。 + /// 0.0082 四舍五入后实际结算 0.01,待实现应为 0.0082 - 0.01 = -0.0018。 + /// + [TestMethod] + public void DI_AUTO_SETTLEMENT_001_非最终自动互换保留舍入尾差() + { + var service = new StubEodPositionService + { + AutoInterests = new List { CreateAutoSwapFlowEvent(new DateTime(2026, 5, 10), 0.0082m) } + }; + var td = CreateTrade(); + var position = CreateInterestPosition(); + var result = service.ExecuteSaveAutoEodInterestPosition( + CreatePreEod(new DateTime(2026, 5, 9), 0m), position, td, new DateTime(2026, 5, 10), + new IntervalModel { Date = new DateTime(2026, 5, 10), Rate = FixedRate, Settlement = 1 }); + + AssertDecimal(0.01m, result.TdCloseInterest, "日终当日已实现必须使用两位实际结算金额"); + AssertDecimal(-0.0018m, result.InterestIncomeSum, "非最终结算的尾差必须继续留在待实现"); + AssertDecimal(0.01m, service.AutoInterests.Single().InterestAmount, "自动互换流水金额必须为两位"); + AssertDecimal(0.01m, service.AutoInterests.Single().InterestClosePnL, "资金汇总使用的流水损益必须为两位"); + } + + /// + /// [DI_AUTO_SETTLEMENT_002] 到期自动互换:仍按两位实际结算,但不存在后续计息时待实现必须清零。 + /// + [TestMethod] + public void DI_AUTO_SETTLEMENT_002_到期自动互换清零待实现() + { + var service = new StubEodPositionService + { + AutoInterests = new List { CreateAutoSwapFlowEvent(ExerciseDate, 0.0082m) } + }; + var td = CreateTrade(); + var position = CreateInterestPosition(); + var result = service.ExecuteSaveAutoEodInterestPosition( + CreatePreEod(ExerciseDate.AddDays(-1), 0m), position, td, ExerciseDate, + new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 1 }); + + AssertDecimal(0.01m, result.TdCloseInterest, "到期自动结算仍按金额两位落库"); + AssertDecimal(0m, result.InterestIncomeSum, "到期最终自动结算后不得遗留待实现尾差"); + } + + /// + /// [DI_AUTO_SETTLEMENT_003] 自动互换后的后续部分平仓必须续接尾差和累计已实现。 + /// 7/7 自动互换将 0.008191780822 按 0.01 实际结算,留下 -0.001808219178; + /// 7/8 平仓一半后,待实现继续参与计算,7/9 全平时才清零。 + /// + [TestMethod] + public void DI_AUTO_SETTLEMENT_003_自动互换后部分平仓续接尾差和已实现() + { + var service = new StubEodPositionService + { + AutoInterests = new List + { + CreateAutoSwapFlowEvent(new DateTime(2026, 5, 10), 0.008191780822m) + } + }; + var td = CreateTrade(); + var position = CreateInterestPosition(); + var autoResult = service.ExecuteSaveAutoEodInterestPosition( + CreatePreEod(new DateTime(2026, 5, 9), 0m), position, td, new DateTime(2026, 5, 10), + new IntervalModel { Date = new DateTime(2026, 5, 10), Rate = FixedRate, Settlement = 1 }); + + var firstCloseDate = new DateTime(2026, 5, 11); + var firstCloseFlow = CreateSwapFlowEvent(firstCloseDate, 0.01m); + firstCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; + firstCloseFlow.InterestPrincipal = 50m; + // 模拟 CalcUnwindInterest: 上日尾差 + 本次平仓后的高精度待实现。 + service.AutoInterests = new List + { + CreateAutoSwapFlowEvent(firstCloseDate, 0.006383561644m) + }; + service.AutoInterests[0].InterestPrincipal = 50m; + var firstCloseResult = service.ExecuteSaveAutoEodWithCloseInterestPosition( + autoResult, position, td, firstCloseDate, null, 50m, 0m, + new List { firstCloseFlow }, 50m, false); + + AssertDecimal(-0.001808219178m, service.LastInterestCalculationEodPosition.InterestProfitSum, + "部分平仓计算必须带入自动互换遗留的待实现尾差"); + Assert.AreEqual(position.id, service.LastInterestCalculationEodPosition.PositionId, + "部分平仓计息必须按腿标识匹配上一日日终"); + AssertDecimal(0.006383561644m, firstCloseResult.InterestIncomeSum, + "部分平仓后待实现应延续历史尾差"); + AssertDecimal(0.02m, firstCloseResult.RealizedInterest, + "部分平仓后累计已实现应包含此前自动互换和本次平仓"); + + var finalCloseDate = firstCloseDate.AddDays(1); + var finalCloseFlow = CreateSwapFlowEvent(finalCloseDate, 0.01m); + finalCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; + finalCloseFlow.InterestPrincipal = 50m; + service.AutoInterests = new List + { + CreateAutoSwapFlowEvent(finalCloseDate, 0.010479452055m) + }; + var finalCloseResult = service.ExecuteSaveAutoEodWithCloseInterestPosition( + firstCloseResult, position, td, finalCloseDate, null, 0m, 0m, + new List { finalCloseFlow }, 50m, false); + + AssertDecimal(0m, finalCloseResult.InterestIncomeSum, "全平后待实现应清零"); + AssertDecimal(0.03m, finalCloseResult.RealizedInterest, + "全平后累计已实现应包含自动互换和两次平仓"); + } + + /// + /// [DI_MATURITY_SETTLEMENT_001] 到期日存在手动互换但未带齐待实现时不能清零; + /// 当前事件按两位覆盖全部可结金额后,才可视为最终结算并清零。 + /// + [TestMethod] + public void DI_MATURITY_SETTLEMENT_001_到期手动互换仅在结清后清零() + { + var settleDate = ExerciseDate; + var td = CreateTrade(); + var position = CreateInterestPosition(); + position.InterestPrincipalFix = 0m; + + // 事件未结算此前的 0.0082:到期日也必须保留待实现。 + var incompleteService = new StubEodPositionService(); + var incompleteEvent = CreateSwapFlowEvent(settleDate, 0m); + incompleteEvent.InterestPrincipal = 0m; + incompleteEvent.InterestRate = 0m; + var incompleteResult = incompleteService.ExecuteSaveEodInterestPosition( + CreatePreEod(settleDate.AddDays(-1), 0.0082m), null, position, td, settleDate, + new List { incompleteEvent }); + AssertDecimal(0.0082m, incompleteResult.InterestIncomeSum, "到期但未结清时不得丢弃历史待实现"); + + // 前端按两位提交 0.01,可覆盖 0.0082 的最终金额,允许清零。 + var finalService = new StubEodPositionService(); + var finalEvent = CreateSwapFlowEvent(settleDate, 0.01m); + finalEvent.InterestPrincipal = 0m; + finalEvent.InterestRate = 0m; + var finalResult = finalService.ExecuteSaveEodInterestPosition( + CreatePreEod(settleDate.AddDays(-1), 0.0082m), null, position, td, settleDate, + new List { finalEvent }); + AssertDecimal(0m, finalResult.InterestIncomeSum, "两位最终结算覆盖待实现后应清零"); + } + + #endregion + #endregion } } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index ca4cf2e4..8ca05ad6 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1078,16 +1078,24 @@ namespace YLErp.Modules.SwapModule intersetAcmount /= tradeExtend.AnnualDays; } newEodPayPosition.TdInterestIncome = intersetAcmount;// 要算一下当天产生的利息 - if (valueDate >= td.ExerciseDate.Value) + var interestIncomeBeforeSettlement = eodPayPosition.InterestIncomeSum + newEodPayPosition.TdInterestIncome; + var interestFeeBeforeSettlement = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee; + var isMaturityFinalSettlement = valueDate.Date >= td.ExerciseDate.Value.Date + && flowEvents.Any() + && RoundMoney(interestIncomeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterest) + && RoundMoney(interestFeeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterestFee); + + if (isMaturityFinalSettlement) { - // 当前分支已有最终收益结算事件:该事件已包含待实现余额并按两位结出,快照不再留存尾差。 + // 到期日不是清零的充分条件。只有当前事件按金额两位覆盖本腿全部可结金额, + // 才能确认是最终结算;否则保留尾差,避免手工互换少结时永久丢失待实现。 newEodPayPosition.InterestIncomeSum = 0; newEodPayPosition.InterestFeeSum = 0; } else { - newEodPayPosition.InterestIncomeSum = eodPayPosition.InterestIncomeSum + newEodPayPosition.TdInterestIncome - newEodPayPosition.TdCloseInterest; - newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee; + newEodPayPosition.InterestIncomeSum = RoundEodInterest(interestIncomeBeforeSettlement - newEodPayPosition.TdCloseInterest); + newEodPayPosition.InterestFeeSum = RoundEodInterest(interestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee); } newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; //持仓价值 @@ -1189,8 +1197,17 @@ namespace YLErp.Modules.SwapModule orginPv = eodPayPosition.InterestPrincipalFix; } var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, posiNotionalValue, closePercent, (int)SwapEventTypeEnum.自动互换, false, true, grossPrice, orginPv, true); - decimal InterestAmount = interests.Sum(x => x.InterestAmount); - decimal TdInterestAmount = interests.Sum(x => x.TdInterestAmount); + decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount); + decimal tdInterestAmount = interests.Sum(x => x.TdInterestAmount); + + // 自动互换的流水和客户资金都由 InterestClosePnL 汇总。先把实际结算收敛到两位, + // 日终快照仍使用上面的高精度应结金额计算待实现尾差,避免把舍入差提前丢掉。 + interests.ForEach(x => + { + x.InterestAmount = RoundMoney(x.InterestAmount); + x.InterestClosePnL = RoundMoney(x.InterestClosePnL); + }); + decimal settledInterestAmount = interests.Sum(x => x.InterestAmount); newEodPayPosition.ValueDate = valueDate; newEodPayPosition.PositionId = position.id; @@ -1216,13 +1233,19 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.TdInterestRate = interval.Rate; //当日已实现 //newEodPayPosition.TdInterestFee = 0; - newEodPayPosition.TdCloseInterest = InterestAmount; + newEodPayPosition.TdCloseInterest = settledInterestAmount; // newEodPayPosition.TdCloseInterestFee = newEodPayPosition.TdInterestFee; //持仓内容-利息腿-损益统计(本方视角) - newEodPayPosition.TdInterestIncome = TdInterestAmount; + newEodPayPosition.TdInterestIncome = tdInterestAmount; Log.Info($"InterestFeeSum is {eodPayPosition.InterestFeeSum},TdInterestFee is {newEodPayPosition.TdInterestFee},TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}"); - newEodPayPosition.InterestIncomeSum = 0; - newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee; + var isMaturityFinalAutoSettlement = valueDate.Date >= td.ExerciseDate.Value.Date; + // 到期自动互换是最后一次自动结算:两位实际金额已落流水/资金,待实现不再滚入下一日。 + newEodPayPosition.InterestIncomeSum = isMaturityFinalAutoSettlement + ? 0 + : RoundEodInterest(interestAmountBeforeSettlement - settledInterestAmount); + newEodPayPosition.InterestFeeSum = isMaturityFinalAutoSettlement + ? 0 + : RoundEodInterest(eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee); newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; //持仓价值 newEodPayPosition.SwapPositionValue = newEodPayPosition.InterestProfitSum * ratio + newEodPayPosition.PosiProfitSum; @@ -1264,12 +1287,26 @@ namespace YLErp.Modules.SwapModule ratio = -ratio; } // 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。 + // 部分平仓仍要续接上一日日终:CalcUnwindInterest 会将 InterestProfitSum + // 加入本次待实现,已实现字段也必须按日累计,不能从新建的临时对象重新开始。 var lastInterestIncomeSum = eodPayPosition?.InterestIncomeSum ?? 0m; + var lastInterestFeeSum = eodPayPosition?.InterestFeeSum ?? 0m; + var lastRealizedInterest = eodPayPosition?.RealizedInterest ?? 0m; + var lastRealizedInterestFee = eodPayPosition?.RealizedInterestFee ?? 0m; eodPayPosition = new eod_swap_position(); eodPayPosition.ClientId = td.ClientId; eodPayPosition.SwapTradeId = td.id; + // CalcSwapInterests 按 PositionId 查找上一日日终;id 仍保持 0,沿用盘中平仓的原有计息日期语义。 + eodPayPosition.PositionId = position.id; eodPayPosition.PosiStartDate = td.StartDate.Value; eodPayPosition.PosiMatuirityDate = td.ExerciseDate.Value; + eodPayPosition.InterestIncomeSum = lastInterestIncomeSum; + eodPayPosition.InterestFeeSum = lastInterestFeeSum; + // 盘中计息结果 InterestAmount 只承接利息待实现;费用仍由 InterestFeeSum + // 独立滚存,避免后续汇总 InterestProfitSum 时把历史费用重复计算。 + eodPayPosition.InterestProfitSum = lastInterestIncomeSum; + eodPayPosition.RealizedInterest = lastRealizedInterest; + eodPayPosition.RealizedInterestFee = lastRealizedInterestFee; if (newEodPayPosition == null) { newEodPayPosition = eodPayPosition.Clone();