diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs index f1e87b49..ba6f8790 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs @@ -92,10 +92,12 @@ namespace YLErp.Modules.SwapModule // public 包装:验证自动互换时的“高精度应结 -> 两位实际结算 -> 待实现尾差”链路。 public eod_swap_position ExecuteSaveAutoEodInterestPosition( eod_swap_position eodPayPosition, swap_position position, trade td, - DateTime valueDate, IntervalModel interval) + DateTime valueDate, IntervalModel interval, eod_swap lastEodSwap = null, + decimal posiLongNotional = DealInterestsScenarioTest.Principal, + decimal orginPv = DealInterestsScenarioTest.Principal) { SaveAutoEodInterestPosition(eodPayPosition, null, position, td, valueDate, interval, - null, DealInterestsScenarioTest.Principal, 0m, 1m, DealInterestsScenarioTest.Principal); + lastEodSwap, posiLongNotional, 0m, 1m, orginPv); return PersistedPositions.LastOrDefault(); } @@ -697,10 +699,10 @@ namespace YLErp.Modules.SwapModule var firstCloseFlow = CreateSwapFlowEvent(firstCloseDate, 0.01m); firstCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; firstCloseFlow.InterestPrincipal = 50m; - // 模拟 CalcUnwindInterest: 上日尾差 + 本次平仓后的高精度待实现。 + // 模拟 CalcUnwindInterest:上日尾差加当日新增,尚未扣除本次 0.01 平仓结算。 service.AutoInterests = new List { - CreateAutoSwapFlowEvent(firstCloseDate, 0.006383561644m) + CreateAutoSwapFlowEvent(firstCloseDate, 0.016383561644m) }; service.AutoInterests[0].InterestPrincipal = 50m; var firstCloseResult = service.ExecuteSaveAutoEodWithCloseInterestPosition( @@ -733,6 +735,239 @@ namespace YLErp.Modules.SwapModule "全平后累计已实现应包含自动互换和两次平仓"); } + [TestMethod] + public void DI_AUTO_SETTLEMENT_004_PartialCloseAccruesOnlyAfterPreviousEod() + { + const decimal originalNotional = 10012.35m; + const decimal remainingNotional = 5006.17m; + const decimal closeNotional = 5006.172835m; + const decimal rate = 0.0299m; + const decimal pendingInterest = 0.820379534246m; + const decimal settledInterest = 0.82m; + const decimal expectedPendingInterest = 0.820569301369m; + var service = new StubEodPositionService(); + var td = CreateTrade(); + td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "01", + SettlementRules = 0 + }); + var position = CreateInterestPosition(); + position.InterestRateDefault = rate; + position.InterestSwapInterval = null; + var previousEodDate = StartDate.AddDays(2); + var closeDate = previousEodDate.AddDays(1); + var previousEod = CreatePreEod(previousEodDate, pendingInterest, settledInterest); + previousEod.TdInterestPrincipal = originalNotional; + var closeFlow = CreateSwapFlowEvent(closeDate, settledInterest); + closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; + closeFlow.InterestPrincipal = 5006.18m; + closeFlow.InterestRate = rate; + + var result = service.ExecuteSaveAutoEodWithCloseInterestPosition( + previousEod, position, td, closeDate, null, + remainingNotional, 0m, new List { closeFlow }, + closeNotional, false); + + AssertDecimal(expectedPendingInterest, result.InterestIncomeSum, + "Partial close must accrue only the day after the previous EOD snapshot"); + AssertDecimal(remainingNotional, result.TdInterestPrincipal, + "The close-day snapshot must carry the remaining principal into the next EOD"); + AssertDecimal(1.64m, result.RealizedInterest, + "Realized interest must include the previous and current settlements"); + Assert.AreEqual(previousEodDate, service.LastInterestCalculationEodPosition.ValueDate, + "The previous EOD ValueDate must be preserved for accrual boundaries"); + Assert.AreEqual(previousEod.id, service.LastInterestCalculationEodPosition.id, + "The previous EOD identity must not be reset to a new position"); + } + + [TestMethod] + public void DI_AUTO_SETTLEMENT_005_AutoSettlementKeepsRemainingPrincipal() + { + const decimal originalNotional = 10012.35m; + const decimal remainingNotional = 5006.17m; + const decimal rate = 0.0299m; + var settlementDate = new DateTime(2026, 7, 14); + var service = new StubEodPositionService(); + var td = CreateTrade(); + td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "01", + SettlementRules = 0 + }); + var position = CreateInterestPosition(); + position.InterestRateDefault = rate; + position.InterestSwapInterval = null; + var previousEod = CreatePreEod(settlementDate.AddDays(-1), 2.460947197259m, 1.64m); + previousEod.TdInterestPrincipal = remainingNotional; + var staleAggregate = new eod_swap { NotionalValue = originalNotional }; + + var result = service.ExecuteSaveAutoEodInterestPosition( + previousEod, position, td, settlementDate, + new IntervalModel { Date = settlementDate, Rate = rate, Settlement = 1 }, + staleAggregate, remainingNotional, originalNotional); + + AssertDecimal(2.87m, result.TdCloseInterest, + "Automatic settlement must round the half-position interest to 2.87"); + AssertDecimal(remainingNotional, result.TdInterestPrincipal, + "Automatic settlement must not restore the original principal from eod_swap"); + } + + [TestMethod] + public void DI_AUTO_SETTLEMENT_006_CloseAndAutoSettlementOnlySettlesRemainder() + { + var settleDate = new DateTime(2026, 7, 16); + var autoFlow = CreateAutoSwapFlowEvent(settleDate, 1.2345m); + var service = new StubEodPositionService + { + AutoInterests = new List { autoFlow } + }; + var closeFlow = CreateSwapFlowEvent(settleDate, 0.50m); + closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; + + var result = service.ExecuteSaveAutoEodWithCloseInterestPosition( + CreatePreEod(settleDate.AddDays(-1), 0m), CreateInterestPosition(), CreateTrade(), + settleDate, new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 }, + 50m, 0m, new List { closeFlow }, 50m, true); + + AssertDecimal(0.73m, autoFlow.InterestAmount, + "Automatic settlement must deduct the 0.50 already settled by the close"); + AssertDecimal(0.73m, autoFlow.InterestClosePnL, + "The automatic flow PnL must use the actual 2-decimal remainder"); + AssertDecimal(1.23m, result.TdCloseInterest, + "EOD realized interest must include both manual and automatic settlements"); + AssertDecimal(0.0045m, result.InterestIncomeSum, + "The high-precision total less actual settlements must remain unrealized"); + AssertDecimal(1.23m, result.RealizedInterest, + "Cumulative realized interest must add the combined actual settlement once"); + } + + [TestMethod] + public void DI_AUTO_SETTLEMENT_007_PayLegKeepsUnsignedSettlementAndAppliesDirectionOnce() + { + var settleDate = new DateTime(2026, 7, 16); + var autoFlow = CreateAutoSwapFlowEvent(settleDate, 1.2345m); + autoFlow.InterestDirection = (int)SwapDirectionEnum.支付; + autoFlow.InterestClosePnL = -1.2345m; + var service = new StubEodPositionService + { + AutoInterests = new List { autoFlow } + }; + var position = CreateInterestPosition(); + position.InterestDirection = (int)SwapDirectionEnum.支付; + var closeFlow = CreateSwapFlowEvent(settleDate, 0.50m); + closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; + closeFlow.InterestDirection = (int)SwapDirectionEnum.支付; + closeFlow.InterestClosePnL = -0.50m; + + var result = service.ExecuteSaveAutoEodWithCloseInterestPosition( + CreatePreEod(settleDate.AddDays(-1), 0m), position, CreateTrade(), settleDate, + new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 }, + 50m, 0m, new List { closeFlow }, 50m, true); + + AssertDecimal(0.73m, autoFlow.InterestAmount); + AssertDecimal(-0.73m, autoFlow.InterestClosePnL); + AssertDecimal(1.23m, result.TdCloseInterest, + "TdCloseInterest follows the unsigned settlement convention used by other interest branches"); + AssertDecimal(0.0045m, result.InterestIncomeSum); + AssertDecimal(-1.23m, result.RealizedInterest, + "The pay direction must be applied exactly once when cumulative realized interest is stored"); + } + + [TestMethod] + public void DI_AUTO_SETTLEMENT_008_MarginLegAppliesReversedDirectionOnce() + { + var settleDate = new DateTime(2026, 7, 16); + var autoFlow = CreateAutoSwapFlowEvent(settleDate, 1.2345m); + autoFlow.InterestMode = (int)InterestModeEnum.初始预付金; + var service = new StubEodPositionService + { + AutoInterests = new List { autoFlow } + }; + var position = CreateInterestPosition(); + position.InterestMode = (int)InterestModeEnum.初始预付金; + position.InterestPrincipalFix = 50m; + var closeFlow = CreateSwapFlowEvent(settleDate, 0.50m); + closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; + closeFlow.InterestMode = (int)InterestModeEnum.初始预付金; + + var result = service.ExecuteSaveAutoEodWithCloseInterestPosition( + CreatePreEod(settleDate.AddDays(-1), 0m), position, CreateTrade(), settleDate, + new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 }, + 50m, 0m, new List { closeFlow }, 50m, true); + + AssertDecimal(0.73m, autoFlow.InterestAmount); + AssertDecimal(0.73m, autoFlow.InterestClosePnL); + AssertDecimal(1.23m, result.TdCloseInterest); + AssertDecimal(0.0045m, result.InterestIncomeSum); + AssertDecimal(-1.23m, result.RealizedInterest, + "A received margin principal produces payable interest, so the margin ratio reverses once"); + } + + [TestMethod] + public void DI_MANUAL_CLOSE_006_FinalCloseIncludesCloseDateInterest() + { + const decimal originalNotional = 10012.35m; + const decimal remainingNotional = 5006.17m; + const decimal rate = 0.0299m; + const decimal pendingInterest = 0.411136145205m; + const decimal expectedInterest = 0.821230619178m; + var closeDate = new DateTime(2026, 7, 16); + var service = new StubEodPositionService(); + var td = CreateTrade(); + td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "01", + SettlementRules = 0 + }); + var position = CreateInterestPosition(); + position.InterestRateDefault = rate; + position.InterestSwapInterval = null; + var previousEod = CreatePreEod(closeDate.AddDays(-1), pendingInterest, 4.51m); + previousEod.TdInterestPrincipal = remainingNotional; + var previousFloatingPosition = new eod_swap_position + { + PosiDirection = (int)SwapDirectionEnum.支付, + PosiNotionalValue = remainingNotional + }; + var previousAggregate = new eod_swap + { + NotionalValue = originalNotional, + NotionalValueLong = remainingNotional + }; + var orginPv = SwapDealService.ResolveUnwindPreviousNotional( + previousAggregate, new List { previousEod, previousFloatingPosition }, + remainingNotional); + + AssertDecimal(remainingNotional, SwapDealService.ResolveUnwindPreviousNotional( + previousAggregate, Array.Empty(), originalNotional), + "Missing details must fall back to the aggregate directional notionals"); + AssertDecimal(remainingNotional, SwapDealService.ResolveUnwindPreviousNotional( + null, null, remainingNotional), + "Missing EOD data must fall back to the current notional"); + AssertDecimal(remainingNotional, SwapDealService.ResolveUnwindPreviousNotional( + new eod_swap { NotionalValue = originalNotional }, Array.Empty(), + remainingNotional), + "Zero directional notionals must not override a non-zero current remaining notional"); + + var result = new SwapDealService(service).GetInterests( + td, td.trade_extend, closeDate, closeDate, + new List { previousEod }, new List { position }, + remainingNotional, remainingNotional, 0m, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, false, 1m, orginPv, + false, settment: false, newCalcLast: false, closeList: null).Single(); + + AssertDecimal(remainingNotional, result.InterestPrincipal, + "Final close must accrue on the remaining principal"); + AssertDecimal(expectedInterest, result.InterestAmount, + "InterestCalcMode 01 must include the final close date"); + AssertDecimal(0.82m, Math.Round(result.InterestAmount, ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero), "Final close cash interest must be 0.82"); + } + /// /// [DI_MATURITY_SETTLEMENT_001] 到期日存在手动互换但未带齐待实现时不能清零; /// 当前事件按两位覆盖全部可结金额后,才可视为最终结算并清零。 diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 20237217..70a5e50e 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -565,7 +565,7 @@ namespace YLErp.Modules.SwapModule var posiShortNotionalValue = shortPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金 var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue); var posiNotionalValue = stockEqvNotional * closePercent;//剩余名义本金 - var orginPv = lastEod != null ? lastEod.NotionalValue : stockEqvNotional; + var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional); var grossPrice = realPostitions.Where(x => x.PosiDirection > 0).FirstOrDefault()?.PosiGrossPrice; var closeList = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId && x.UnwindDate == unwindDate && eventTypes.Contains(x.EventType) && x.DataState == (int)SwapFlowDateStateEnum.完成).ToList(); bool tdClose = closeList.Count > 0; @@ -607,6 +607,29 @@ namespace YLErp.Modules.SwapModule }).ToList(); } + public static decimal ResolveUnwindPreviousNotional( + eod_swap lastEod, + IEnumerable lastEodPositions, + decimal currentNotional) + { + var floatingPositions = lastEodPositions?.Where(x => x.PosiDirection > 0).ToList(); + decimal previousNotional; + if (floatingPositions?.Count > 0) + { + previousNotional = floatingPositions.Sum(x => x.PosiNotionalValue); + } + else + { + previousNotional = lastEod == null + ? currentNotional + : Math.Abs(lastEod.NotionalValueLong) + Math.Abs(lastEod.NotionalValueShort); + } + + return previousNotional == 0m && currentNotional != 0m + ? currentNotional + : previousNotional; + } + /// /// 获取利息腿"已通过历史互换结出的累计利息"(用于复利重算时扣除,类比分红的 CalcConsumedDividend)。 /// 数据源为事件级 swap_flow_event.InterestAmount(互换/自动互换 完成态事件,互换当时即落库,不依赖日终归档)。 diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 8ca05ad6..cb0458b7 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1161,13 +1161,7 @@ namespace YLErp.Modules.SwapModule } var tradeExtend = td.trade_extend.ExtendObj; - decimal oriPosiNotionalValue = posiLongNotional + posiShortNational; - decimal posiNotionalValue = oriPosiNotionalValue; - bool longShort = td.StructureType == ClientMarginTypeEnum.多空组合.ToString(); - if (lastEodSwap != null) - { - posiNotionalValue = lastEodSwap.NotionalValue; - } + decimal posiNotionalValue = posiLongNotional + posiShortNational; decimal closePercent = 1; decimal ratio = position.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m;//收取为正,支付为负 if (marginTypes.Contains(position.InterestMode)) @@ -1293,10 +1287,11 @@ namespace YLErp.Modules.SwapModule var lastInterestFeeSum = eodPayPosition?.InterestFeeSum ?? 0m; var lastRealizedInterest = eodPayPosition?.RealizedInterest ?? 0m; var lastRealizedInterestFee = eodPayPosition?.RealizedInterestFee ?? 0m; - eodPayPosition = new eod_swap_position(); + // 保留上一日日终标识和计息上下文,部分平仓只从 ValueDate 之后续算,不能重置到交易起始日。 + eodPayPosition = eodPayPosition?.Clone() ?? new eod_swap_position(); eodPayPosition.ClientId = td.ClientId; eodPayPosition.SwapTradeId = td.id; - // CalcSwapInterests 按 PositionId 查找上一日日终;id 仍保持 0,沿用盘中平仓的原有计息日期语义。 + // CalcSwapInterests 按 PositionId 匹配上一日日终。 eodPayPosition.PositionId = position.id; eodPayPosition.PosiStartDate = td.StartDate.Value; eodPayPosition.PosiMatuirityDate = td.ExerciseDate.Value; @@ -1338,7 +1333,17 @@ namespace YLErp.Modules.SwapModule preEodPositions.Add(eodPayPosition); var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, closeNational, 1, eventType, false, true, grossPrice, orginPv, true, settment: false, newCalcLast: true); decimal TdInterestAmount = interests.Sum(x => x.TdInterestAmount); - decimal InterestAmount = interests.Sum(x => x.InterestAmount); + decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount); + decimal manualSettledInterestAmount = flowEvents.Sum(x => x.InterestAmount); + decimal autoSettledInterestAmount = 0m; + if (autoSwap && interests.Count > 0) + { + autoSettledInterestAmount = RoundMoney(interestAmountBeforeSettlement - manualSettledInterestAmount); + var autoInterest = interests[0]; + autoInterest.InterestAmount = autoSettledInterestAmount; + autoInterest.InterestClosePnL = autoSettledInterestAmount + * (autoInterest.InterestDirection == (int)SwapDirectionEnum.收取 ? 1m : -1m); + } newEodPayPosition.ValueDate = valueDate; newEodPayPosition.PositionId = position.id; UpdateDbOption(newEodPayPosition); @@ -1359,7 +1364,9 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.interest_rest_days = position.interest_rest_days; newEodPayPosition.interest_rule = position.interest_rule; //利息端估值用信息 - newEodPayPosition.TdInterestPrincipal = interests.Count > 0 ? interests.First().InterestPrincipal : 0; + newEodPayPosition.TdInterestPrincipal = position.InterestMode == (int)InterestModeEnum.标的期初全价 + ? posiNotionalValue + : interests.Count > 0 ? interests.First().InterestPrincipal : 0; if (interval != null) { newEodPayPosition.TdInterestRate = interval.Rate; @@ -1371,7 +1378,7 @@ namespace YLErp.Modules.SwapModule //当日已实现,平仓时已处理 newEodPayPosition.TdInterestFee = flowEvents.Sum(s => s.InterestFee); newEodPayPosition.TdCloseInterestFee = newEodPayPosition.TdInterestFee; - newEodPayPosition.TdCloseInterest = flowEvents.Sum(s => s.InterestClosePnL); + newEodPayPosition.TdCloseInterest = manualSettledInterestAmount + autoSettledInterestAmount; var intersetAcmount = newEodPayPosition.TdInterestPrincipal * (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate); if (position.IsAnnualized) { @@ -1390,7 +1397,8 @@ namespace YLErp.Modules.SwapModule } else { - newEodPayPosition.InterestIncomeSum = InterestAmount; + newEodPayPosition.InterestIncomeSum = RoundEodInterest( + interestAmountBeforeSettlement - newEodPayPosition.TdCloseInterest); newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee; } //持仓内容-利息腿-损益统计(本方视角)