From 6fdc7d80b2cd7cac096f2ec490556f54b7268e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Sat, 8 Aug 2026 15:51:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(swap):=20=E4=BF=AE=E5=A4=8D=E5=A4=8D?= =?UTF-8?q?=E5=88=A9=E8=AE=A1=E7=AE=97=E5=92=8C=E9=83=A8=E5=88=86=E5=B9=B3?= =?UTF-8?q?=E4=BB=93=E5=A4=84=E7=90=86=E4=B8=AD=E7=9A=84=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=88=E7=AE=97=E5=A4=B4=E7=AE=97=E5=B0=BE?= =?UTF-8?q?=E5=90=88=E7=BA=A6=E5=A4=8D=E5=88=A9=E5=B9=B3=E4=BB=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复合约名义本金规模模式下部分平仓时名义本金计算错误 - 修复最终全平时复利利息计算中历史平仓尾差处理问题 - 修复复利重置日处理中利息重复注入历史本金的错误 - 修复部分平仓后日终待实现利息计算中本金比例应用问题 - 修复全平且实际金额覆盖应结利息后待实现利息清零逻辑 - 新增复合复利交换服务用于单元测试验证 - 添加多个测试用例覆盖复利计算边界场景 --- .../SwapModule/DealInterestsScenarioTest.cs | 456 +++++++++++++++++- .../Modules/SwapModule/SwapDealService.cs | 36 +- .../SwapModule/SwapEodPositionService.cs | 67 ++- 3 files changed, 538 insertions(+), 21 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs index 14dbc782..c5a79f9f 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs @@ -48,6 +48,8 @@ namespace YLErp.Modules.SwapModule /// public List AutoInterests { get; set; } + public SwapDealService DealService { get; set; } + public eod_swap_position LastInterestCalculationEodPosition { get; private set; } public StubEodPositionService() : base(nameof(DealInterestsScenarioTest)) @@ -74,7 +76,7 @@ namespace YLErp.Modules.SwapModule return AutoInterests; } - return new SwapDealService(this).GetInterests(td, tradeExtend, valueDate, unwindDate, + return (DealService ?? new SwapDealService(this)).GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, grossPrice, orginPv, add, settment, newCalcLast, closeList); @@ -126,6 +128,27 @@ namespace YLErp.Modules.SwapModule } } + private sealed class StubCompoundSwapDealService : SwapDealService + { + private readonly IReadOnlyDictionary _floatRates; + + public StubCompoundSwapDealService(IReadOnlyDictionary floatRates = null) + : base(new OptUserInfo(0, nameof(StubCompoundSwapDealService), OptUserFrom.UnitTest)) + { + _floatRates = floatRates ?? new Dictionary(); + } + + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { + return _floatRates.TryGetValue(valueDate.Date, out rate); + } + + public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) + { + return 0m; + } + } + #endregion #region 数据构建器 @@ -1085,6 +1108,437 @@ namespace YLErp.Modules.SwapModule MidpointRounding.AwayFromZero), "Final close cash interest must be 0.82"); } + [TestMethod] + public void DI_MANUAL_CLOSE_007_CompoundFinalCloseSettlesFirstPartialCloseRoundingTail() + { + const decimal originalNotional = 10012.35m; + const decimal remainingNotional = originalNotional / 2m; + const decimal rate = 0.0299m; + var firstCloseDate = StartDate.AddDays(6); + var finalCloseDate = firstCloseDate.AddDays(6); + var td = CreateTrade(); + td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "11", + SettlementRules = 0 + }); + var position = CreateInterestPosition(); + position.InterestType = (int)InterestTypeEnum.复利; + position.InterestRateDefault = rate; + position.InterestPrincipalFix = originalNotional; + position.interest_rest_days = 1; + position.InterestSwapInterval = null; + var dealService = new StubCompoundSwapDealService(); + var eodService = new StubEodPositionService { DealService = dealService }; + + var firstCloseInterest = dealService.GetInterests( + td, td.trade_extend, firstCloseDate, firstCloseDate, + new List(), new List { position }, + originalNotional, originalNotional, 0m, remainingNotional, 0.5m, + (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + settment: false).Single(); + var firstCloseCash = Math.Round(firstCloseInterest.InterestAmount, ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + var firstCloseRoundingTail = firstCloseInterest.InterestAmount - firstCloseCash; + Assert.AreNotEqual(0m, firstCloseRoundingTail, + $"首次部分平仓高精度利息 {firstCloseInterest.InterestAmount:F12} 按两位实际结算 {firstCloseCash:F2},尾差 {firstCloseRoundingTail:F12}"); + var firstCloseFlow = CreateSwapFlowEvent(firstCloseDate, firstCloseCash); + firstCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; + firstCloseFlow.InterestPrincipal = remainingNotional; + var firstCloseEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition( + null, position, td, firstCloseDate, null, remainingNotional, 0m, + new List { firstCloseFlow }, remainingNotional, false); + + var replayAtPreviousEod = dealService.GetInterests( + td, td.trade_extend, firstCloseDate, firstCloseDate, + new List(), new List { position }, + remainingNotional, remainingNotional, 0m, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + settment: false).Single(); + var replayAtFinalClose = dealService.GetInterests( + td, td.trade_extend, finalCloseDate, finalCloseDate, + new List(), new List { position }, + remainingNotional, remainingNotional, 0m, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + settment: false).Single(); + var expectedFinalInterest = firstCloseEod.InterestIncomeSum + + replayAtFinalClose.InterestAmount - replayAtPreviousEod.InterestAmount; + var expectedFinalCash = Math.Round(expectedFinalInterest, ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + var expectedTotalCash = Math.Round(firstCloseCash + firstCloseEod.InterestIncomeSum + + replayAtFinalClose.InterestAmount - replayAtPreviousEod.InterestAmount, + ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + + var finalCloseInterest = dealService.GetInterests( + td, td.trade_extend, finalCloseDate, finalCloseDate, + new List { firstCloseEod }, new List { position }, + remainingNotional, remainingNotional, 0m, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + settment: false).Single(); + var finalCloseCash = Math.Round(finalCloseInterest.InterestAmount, ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + var finalCloseFlow = CreateSwapFlowEvent(finalCloseDate, finalCloseCash); + finalCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; + finalCloseFlow.InterestPrincipal = remainingNotional; + var finalCloseEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition( + firstCloseEod, position, td, finalCloseDate, null, 0m, 0m, + new List { finalCloseFlow }, remainingNotional, false); + + AssertDecimal(expectedFinalCash, finalCloseCash, + "最终全平现金必须带走上一日日终的待实现利息尾差"); + AssertDecimal(expectedTotalCash, + firstCloseCash + finalCloseCash, + "两次实际结算现金必须守恒"); + AssertDecimal(0m, finalCloseEod.InterestIncomeSum, "全平且实际金额覆盖应结利息后待实现应清零"); + AssertDecimal(firstCloseCash + finalCloseCash, finalCloseEod.RealizedInterest, + "累计已实现利息必须等于历次实际结算金额之和"); + + var incompleteFinalCloseFlow = CreateSwapFlowEvent(finalCloseDate, finalCloseCash - 0.01m); + incompleteFinalCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; + incompleteFinalCloseFlow.InterestPrincipal = remainingNotional; + var incompleteFinalCloseEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition( + firstCloseEod, position, td, finalCloseDate, null, 0m, 0m, + new List { incompleteFinalCloseFlow }, remainingNotional, false); + AssertDecimal(expectedFinalInterest - incompleteFinalCloseFlow.InterestAmount, + incompleteFinalCloseEod.InterestIncomeSum, + "最终全平流水少结 0.01 时,日终必须保留未结利息而非清零"); + } + + [TestMethod] + public void DI_GLMS_20260421_0007_ContractNotionalCompoundPartialCloseScalesAndFinalCloseUsesRemainingPrincipal() + { + const decimal notional = 303139117.8m; + const decimal partialPercent = 0.3m; + const decimal partialNotional = notional * partialPercent; + const decimal remainingNotional = notional - partialNotional; + const decimal spread = 0.0025m; + var startDate = new DateTime(2026, 4, 21); + var maturityDate = new DateTime(2026, 5, 19); + var partialCloseDate = new DateTime(2026, 5, 11); + var td = new trade + { + id = 7007, + TradeNumber = "GLMS-20260421-0007", + ClientId = 999998, + TradeType = "收益互换", + TradeDate = startDate, + StartDate = startDate, + ExerciseDate = maturityDate, + TradeStatus = "确认成交", + ValidState = "Valid", + trade_extend = new trade_extend + { + TradeId = 7007, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "11", + SettlementRules = 0 + }) + } + }; + var position = new swap_position + { + id = 70071, + SwapTradeId = td.id, + PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.合约名义本金规模, + InterestRateDefault = spread, + InterestPrincipalFix = notional, + PosiStartDate = startDate, + PosiMatuirityDate = maturityDate, + IsInitial = true, + Invalid = false, + InterestType = (int)InterestTypeEnum.复利, + IsAnnualized = true, + interest_rest_days = 7, + interest_rule = 0, + FloatRateUnderlyingCode = "FR007", + InterestSwapInterval = JsonConvert.SerializeObject(new List + { + new IntervalModel { Date = maturityDate, Rate = spread, Settlement = 0 } + }) + }; + var service = new StubCompoundSwapDealService(new Dictionary + { + [new DateTime(2026, 4, 20)] = 0.0132, + [new DateTime(2026, 4, 21)] = 0.0132, + [new DateTime(2026, 4, 22)] = 0.0132, + [new DateTime(2026, 4, 23)] = 0.0132, + [new DateTime(2026, 4, 24)] = 0.0131, + [new DateTime(2026, 4, 27)] = 0.013502, + [new DateTime(2026, 4, 28)] = 0.0136, + [new DateTime(2026, 4, 29)] = 0.0138, + [new DateTime(2026, 4, 30)] = 0.0139, + [new DateTime(2026, 5, 4)] = 0.0139, + [new DateTime(2026, 5, 5)] = 0.0139, + [new DateTime(2026, 5, 6)] = 0.0136, + [new DateTime(2026, 5, 7)] = 0.0136, + [new DateTime(2026, 5, 8)] = 0.0135, + [new DateTime(2026, 5, 9)] = 0.0131, + [new DateTime(2026, 5, 11)] = 0.0134, + [new DateTime(2026, 5, 12)] = 0.013, + [new DateTime(2026, 5, 13)] = 0.0129, + [new DateTime(2026, 5, 14)] = 0.013, + [new DateTime(2026, 5, 15)] = 0.013, + [new DateTime(2026, 5, 18)] = 0.0132, + [new DateTime(2026, 5, 19)] = 0.0131 + }); + var previousEod = new eod_swap_position + { + id = 70072, + SwapTradeId = td.id, + PositionId = position.id, + ValueDate = new DateTime(2026, 5, 10), + TdInterestPrincipal = 303324019.3183441374m, + InterestIncomeSum = 266674.349853521170m, + InterestProfitSum = 266674.349853521170m, + FloatRate = 0.0139m + }; + + var partial = service.GetInterests( + td, td.trade_extend, partialCloseDate, partialCloseDate, + new List { previousEod }, new List { position }, + notional, notional, 0m, partialNotional, partialPercent, + (int)SwapEventTypeEnum.平仓, false, false, 0m, notional, + settment: false).Single(); + AssertDecimal(84090.95m, Math.Round(partial.InterestAmount, ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero), + "GLMS-20260421-0007 30% 复利合约名义本金平仓必须按比例结算"); + + var final = service.GetInterests( + td, td.trade_extend, maturityDate, maturityDate, + new List(), new List { position }, + remainingNotional, remainingNotional, 0m, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, false, 0m, remainingNotional, + settment: false, newCalcLast: true).Single(); + AssertDecimal(268428.73m, Math.Round(final.InterestAmount, ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero), + "后续全平必须只结算剩余70%本金的复利,不重复结算原始全额"); + Assert.AreNotEqual(280303.16m, + Math.Round(final.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero), + "后续全平不得再次使用原始全额本金"); + } + + [TestMethod] + public void DI_GLMS_20260421_0007_EodPartialCloseCarriesRemainingCompoundAccrual() + { + const decimal originalNotional = 303139117.8m; + const decimal partialNotional = 90941735.34m; + const decimal remainingNotional = 212197382.46m; + const decimal spread = 0.0025m; + var partialCloseDate = new DateTime(2026, 5, 11); + var intermediateDate = new DateTime(2026, 5, 18); + var finalCloseDate = new DateTime(2026, 5, 19); + var td = new trade + { + id = 7007, + TradeNumber = "GLMS-20260421-0007-EOD", + ClientId = 999998, + TradeType = "收益互换", + TradeDate = new DateTime(2026, 4, 21), + StartDate = new DateTime(2026, 4, 21), + ExerciseDate = finalCloseDate, + TradeStatus = "确认成交", + ValidState = "Valid", + StructureType = "单标的", + QuoteCurrency = "CNY", + SettlementCurrency = "CNY", + trade_extend = new trade_extend + { + TradeId = 7007, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "11", + SettlementRules = 0 + }) + } + }; + var position = new swap_position + { + id = 70071, + SwapTradeId = td.id, + PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.合约名义本金规模, + InterestRateDefault = spread, + InterestPrincipalFix = originalNotional, + PosiStartDate = td.StartDate.Value, + PosiMatuirityDate = finalCloseDate, + IsInitial = true, + Invalid = false, + InterestType = (int)InterestTypeEnum.复利, + IsAnnualized = true, + interest_rest_days = 7, + interest_rule = 0, + FloatRateUnderlyingCode = "FR007", + InterestSwapInterval = JsonConvert.SerializeObject(new List + { + new IntervalModel { Date = finalCloseDate, Rate = spread, Settlement = 0 } + }) + }; + var dealService = new StubCompoundSwapDealService(new Dictionary + { + [new DateTime(2026, 4, 21)] = 0.0132, + [new DateTime(2026, 4, 28)] = 0.0136, + [new DateTime(2026, 4, 30)] = 0.0139, + [new DateTime(2026, 5, 4)] = 0.0139, + [new DateTime(2026, 5, 5)] = 0.0139, + [new DateTime(2026, 5, 6)] = 0.0136, + [new DateTime(2026, 5, 11)] = 0.0134, + [new DateTime(2026, 5, 12)] = 0.013, + [new DateTime(2026, 5, 13)] = 0.0129, + [new DateTime(2026, 5, 19)] = 0.0131 + }); + var eodService = new StubEodPositionService { DealService = dealService }; + var previousEod = new eod_swap_position + { + id = 70072, + SwapTradeId = td.id, + PositionId = position.id, + ValueDate = new DateTime(2026, 5, 10), + InterestDirection = position.InterestDirection, + InterestMode = position.InterestMode, + InterestType = position.InterestType, + InterestRateDefault = spread, + InterestIncomeSum = 266674.349853521170m, + InterestProfitSum = 266674.349853521170m, + TdInterestPrincipal = 303324019.3183441374m, + PosiNotionalValue = originalNotional, + FloatRate = 0.0139m, + IsAnnualized = true, + interest_rest_days = 7, + interest_rule = 0 + }; + var partialCloseFlow = new swap_flow_event + { + SwapTradeId = td.id, + PositionId = position.id, + EventType = (int)SwapFlowEventTypeEnum.平仓, + EventDate = partialCloseDate, + UnwindDate = partialCloseDate, + InterestDirection = position.InterestDirection, + InterestRate = spread, + InterestPrincipal = partialNotional, + InterestAmount = 84090.95m, + InterestClosePnL = 84090.95m, + DataState = (int)SwapFlowDateStateEnum.完成 + }; + + var partialEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition( + previousEod, position, td, partialCloseDate, null, remainingNotional, 0m, + new List { partialCloseFlow }, partialNotional, false); + AssertDecimal(13628.805251563956m, partialEod.TdInterestIncome, + "5/11 EOD 当日新增复利必须按平仓前全额本金计提"); + AssertDecimal(196212.205105085126m, partialEod.InterestIncomeSum, + "5/11 EOD 应保留部分平仓后的待实现复利"); + + var intermediateInterest = dealService.GetInterests( + td, td.trade_extend, intermediateDate, intermediateDate, + new List { partialEod }, new List { position }, + remainingNotional, remainingNotional, 0m, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, false, 0m, originalNotional, + settment: false, newCalcLast: true).Single(); + Assert.IsTrue(Math.Abs(259348.386714765m - intermediateInterest.InterestAmount) <= 0.01m, + $"5/18 复利平仓应承接 5/11 日终剩余本金的累计利息 Expected approximately 259348.386714765, Actual: {intermediateInterest.InterestAmount}"); + + } + + [TestMethod] + public void DI_GLMS_20260421_0006_FinalCloseCarriesPreviousEodInterest() + { + const decimal remainingNotional = 212197382.46m; + const decimal expectedInterest = -119386.71m; + var finalCloseDate = new DateTime(2026, 5, 19); + var td = new trade + { + id = 6006, + TradeNumber = "GLMS-20260421-0006", + ClientId = 999998, + TradeType = "收益互换", + TradeDate = new DateTime(2026, 4, 21), + StartDate = new DateTime(2026, 4, 22), + ExerciseDate = finalCloseDate, + TradeStatus = "确认成交", + ValidState = "Valid", + StructureType = "单标的", + QuoteCurrency = "CNY", + SettlementCurrency = "CNY", + trade_extend = new trade_extend + { + TradeId = 6006, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "10", + SettlementRules = 0 + }) + } + }; + var position = new swap_position + { + id = 60061, + SwapTradeId = td.id, + PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.标的期初全价, + InterestRateDefault = -0.021m, + InterestPrincipalFix = remainingNotional, + PosiStartDate = td.StartDate.Value, + PosiMatuirityDate = td.ExerciseDate.Value, + IsInitial = true, + Invalid = false, + InterestType = (int)InterestTypeEnum.复利, + IsAnnualized = true, + interest_rest_days = 7, + interest_rule = 0, + FloatRateUnderlyingCode = "FR007", + InterestSwapInterval = null + }; + var previousEod = new eod_swap_position + { + id = 60062, + SwapTradeId = td.id, + PositionId = position.id, + ValueDate = new DateTime(2026, 5, 18), + InterestDirection = position.InterestDirection, + InterestMode = position.InterestMode, + InterestType = position.InterestType, + InterestRateDefault = position.InterestRateDefault, + FloatRate = 0.0132m, + InterestIncomeSum = expectedInterest, + InterestProfitSum = expectedInterest, + TdInterestPrincipal = remainingNotional, + PosiNotionalValue = remainingNotional, + IsAnnualized = true, + interest_rest_days = 7, + interest_rule = 0 + }; + var dealService = new StubCompoundSwapDealService(new Dictionary + { + [new DateTime(2026, 4, 22)] = 0.0132, + [new DateTime(2026, 4, 29)] = 0.0138, + [new DateTime(2026, 5, 6)] = 0.0136, + [new DateTime(2026, 5, 13)] = 0.0129, + [new DateTime(2026, 5, 19)] = 0.0131 + }); + + var result = dealService.GetInterests( + td, td.trade_extend, finalCloseDate, finalCloseDate, + new List { previousEod }, new List { position }, + remainingNotional, remainingNotional, 0m, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, false, 0m, remainingNotional, + settment: false).Single(); + + AssertDecimal(expectedInterest, result.InterestAmount, + "GLMS-20260421-0006 最终全平应承接 5/18 日终待实现利息"); + Assert.AreNotEqual(-123072.67m, result.InterestAmount, + "不得回归旧库错误的 -123072.67 最终利息"); + } + /// /// [DI_MATURITY_SETTLEMENT_001] 到期日存在手动互换但未带齐待实现时不能清零; /// 当前事件按两位覆盖全部可结金额后,才可视为最终结算并清零。 diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index d952177a..5598bc63 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -830,6 +830,12 @@ namespace YLErp.Modules.SwapModule // 计算名义本金 var (closePrincipal, posiPrincipal, newClosePercent) = CalcNotionalByMode(position, closePrecent, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue); + if ((InterestModeEnum)position.InterestMode == InterestModeEnum.合约名义本金规模 + || (InterestModeEnum)position.InterestMode == InterestModeEnum.标的期初全价 + && posiNotionalValue == 0m) + { + closePrincipal = closePosiNotionalValue; + } if ((InterestModeEnum)position.InterestMode == InterestModeEnum.追加预付金 || (InterestModeEnum)position.InterestMode == InterestModeEnum.初始预付金) { positionClone.InterestDirection = position.InterestDirection == (int)SwapDirectionEnum.收取 ? (int)SwapDirectionEnum.支付 : (int)SwapDirectionEnum.收取; @@ -910,6 +916,9 @@ namespace YLErp.Modules.SwapModule closePrincipal = posiShort * closePercent; posiPrincipal = posiShort; break; + case InterestModeEnum.合约名义本金规模: + closePrincipal = posiNotional * closePercent; + break; case InterestModeEnum.标的期初全价: closePrincipal = posiNotional * closePercent; break; @@ -1228,6 +1237,24 @@ namespace YLErp.Modules.SwapModule CalcDailyCompoundInterest(endDate, position, closePosiNotionalValue, interest, annualDays, needPrice, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount, consumedInterest, resetCarryInterest); + if (preEodPosition.id != 0 && closePrecent == 1m) + { + // 最终全平只重放上一日终之后的新增利息;历史部分平仓的两位结算尾差已在日终待实现中。 + var interestAtEnd = new swap_flow_event { InterestRate = rate }; + decimal amountAtEnd = 0m; + decimal tdAmountAtEnd = 0m; + CalcDailyCompoundInterest(endDate, position, closePosiNotionalValue, + interestAtEnd, annualDays, needPrice, floateRate, closePrecent, orginPv, + calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest); + var interestAtPreviousEod = new swap_flow_event { InterestRate = rate }; + decimal amountAtPreviousEod = 0m; + decimal tdAmountAtPreviousEod = 0m; + CalcDailyCompoundInterest(preEodPosition.ValueDate, position, closePosiNotionalValue, + interestAtPreviousEod, annualDays, needPrice, floateRate, closePrecent, orginPv, + calcFirst, calcLast, ref amountAtPreviousEod, ref tdAmountAtPreviousEod, consumedInterest); + InterestAmount = preEodPosition.InterestIncomeSum + amountAtEnd - amountAtPreviousEod; + TdInterestAmount = preEodPosition.InterestIncomeSum + tdAmountAtEnd - tdAmountAtPreviousEod; + } } else { @@ -1291,8 +1318,13 @@ namespace YLErp.Modules.SwapModule { if (i % interestPeriod == 0) { - // 复利时:利息并入本金(FR007 取价已提前到 calcFirst/calcLast 跳过之前完成) - var interestToReset = i == 0 || resetCarryInterest == 0m ? interest : resetCarryInterest; + // resetCarryInterest 是上一日终待实现按本次平仓比例分摊后的存量, + // 只能在 endDate 恰好是当前复利重置日时并入本金。历史重置点必须使用 + // 重放到当时的 interest,否则会把上一日终存量反复注入历史本金, + // 例如 0007 的 5/11 部分平仓会由 84,090.95 被多算为 84,114.88。 + var interestToReset = i > 0 && accrueDate == endDate && resetCarryInterest != 0m + ? resetCarryInterest + : interest; dynomicPrincipal = principal + interestToReset; tdDynomicPrincipal = principal + interestToReset; flowEvent.InterestPrincipal = tdDynomicPrincipal; diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index c2f54d6a..deebf999 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1295,10 +1295,14 @@ namespace YLErp.Modules.SwapModule // 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。 // 部分平仓仍要续接上一日日终:CalcUnwindInterest 会将 InterestProfitSum // 加入本次待实现,已实现字段也必须按日累计,不能从新建的临时对象重新开始。 + var hasPreviousEod = eodPayPosition != null && eodPayPosition.id != 0; var lastInterestIncomeSum = eodPayPosition?.InterestIncomeSum ?? 0m; var lastInterestFeeSum = eodPayPosition?.InterestFeeSum ?? 0m; var lastRealizedInterest = eodPayPosition?.RealizedInterest ?? 0m; var lastRealizedInterestFee = eodPayPosition?.RealizedInterestFee ?? 0m; + // 先保留平仓前的复利本金;后面 interests.First().InterestPrincipal 是本次已平部分, + // 不能用它代表平仓前全额本金计算当日总利息。 + var lastTdInterestPrincipal = eodPayPosition?.TdInterestPrincipal ?? 0m; // 保留上一日日终标识和计息上下文,部分平仓只从 ValueDate 之后续算,不能重置到交易起始日。 eodPayPosition = eodPayPosition?.Clone() ?? new eod_swap_position(); eodPayPosition.ClientId = td.ClientId; @@ -1401,28 +1405,55 @@ namespace YLErp.Modules.SwapModule { intersetAcmount /= tradeExtend.AnnualDays; } - newEodPayPosition.TdInterestIncome = !autoSwap && calcLast - ? TdInterestAmount - lastInterestIncomeSum - : intersetAcmount; + newEodPayPosition.TdInterestIncome = autoSwap + ? intersetAcmount + : !hasPreviousEod + ? interestAmountBeforeSettlement + : posiNotionalValue == 0m + ? interestAmountBeforeSettlement - lastInterestIncomeSum + : lastRealizedInterest != 0m || lastRealizedInterestFee != 0m || !calcLast + ? intersetAcmount + : TdInterestAmount - lastInterestIncomeSum; + if (!autoSwap + && calcLast + && closePercent > 0m && closePercent < 1m + && posiNotionalValue > 0m + && position.InterestType == (int)InterestTypeEnum.复利 + && position.InterestMode == (int)InterestModeEnum.合约名义本金规模) + { + // 算尾的复利部分平仓:平仓金额只结算“上日待实现 * 平仓比例 + // + 已平本金当日利息”,但日终待实现必须按“上日待实现 + // + 平仓前全额本金当日利息 - 实际平仓结算”递推。 + // 通用路径的 intersetAcmount 此时基于已平本金:0007 只得到 30% 的 + // 4,088.64,会漏记剩余 70% 的 9,540.16;因此改用上日终全额复利本金, + // 得到当日总利息 13,628.81,剩余部分才能继续参与后续复利。 + var fullPrincipal = lastTdInterestPrincipal > 0m + ? lastTdInterestPrincipal + : oriPosiNotionalValue; + newEodPayPosition.TdInterestIncome = fullPrincipal + * (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate); + if (position.IsAnnualized) + { + newEodPayPosition.TdInterestIncome /= tradeExtend.AnnualDays; + } + } Log.Info($"InterestIncomeSum is {lastInterestIncomeSum},TdInterestIncome is {newEodPayPosition.TdInterestIncome}" + $",TdCloseInterest is {newEodPayPosition.TdCloseInterest}"); Log.Info($"InterestFeeSum is {eodPayPosition.InterestFeeSum},TdInterestFee is {newEodPayPosition.TdInterestFee}" + $",TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}"); - if (closePercent == 1) - { - // 全量平仓后不应把待实现利息或费用带入下一交易日。 - newEodPayPosition.InterestIncomeSum = 0; - newEodPayPosition.InterestFeeSum = 0; - } - else - { - var pendingInterestBeforeSettlement = autoSwap - ? interestAmountBeforeSettlement - : lastInterestIncomeSum + newEodPayPosition.TdInterestIncome; - newEodPayPosition.InterestIncomeSum = RoundEodInterest( - pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest); - newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee; - } + var pendingInterestBeforeSettlement = autoSwap + ? interestAmountBeforeSettlement + : lastInterestIncomeSum + newEodPayPosition.TdInterestIncome; + var pendingInterestFeeBeforeSettlement = eodPayPosition.InterestFeeSum + + newEodPayPosition.TdInterestFee; + newEodPayPosition.InterestIncomeSum = closePercent == 1 + && RoundMoney(pendingInterestBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterest) + ? 0m + : RoundEodInterest(pendingInterestBeforeSettlement - newEodPayPosition.TdCloseInterest); + newEodPayPosition.InterestFeeSum = closePercent == 1 + && RoundMoney(pendingInterestFeeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterestFee) + ? 0m + : RoundEodInterest(pendingInterestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee); //持仓内容-利息腿-损益统计(本方视角) newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; //持仓价值