diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index 8b69eea1..5bbf9dbf 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -36,6 +36,7 @@ namespace YLErp.Modules.SwapModule // 输出别名(转发到基类捕获属性) public List CreatedEodPositions => PersistedPositions; + public List LastInterestCalculationPositions { get; private set; } public TestableSwapEodService( List trades, List positions, @@ -80,7 +81,17 @@ namespace YLErp.Modules.SwapModule decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice, decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, - List closeList = null) => new List(); + List closeList = null) + { + LastInterestCalculationPositions = positions; + return positions.Select(position => new swap_flow_event + { + PositionId = position.id, + InterestPrincipal = 1000m, + InterestRate = 0.01m, + FloatRate = 0.01m + }).ToList(); + } public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate) => SwapPositionCompose(settleDate, preSettleDate, null); @@ -250,5 +261,154 @@ namespace YLErp.Modules.SwapModule Assert.IsTrue(ex.Message.Contains("未收盘"), $"异常消息应含'未收盘',实际:{ex.Message}"); Console.WriteLine($"SPC_004: 抛异常'{ex.Message}' ✅"); } + + [TestMethod] + public void SPC_005_部分平仓后_预付金日终按实时剩余本金计息() + { + const long initialPrepayId = 2; + var td = CreateTrade(); + var initialPrepay = new swap_position + { + id = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 1000m, IsInitial = true, Invalid = false, + PosiStartDate = SettleDate.AddDays(-1), PosiMatuirityDate = td.ExerciseDate.Value, + InterestSwapInterval = "[]" + }; + var realPrepay = new swap_position + { + id = 3, PositionId = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 700m, IsInitial = false, Invalid = false + }; + var prepayEod = new eod_swap_position + { + id = 200, SwapTradeId = SwapTradeId, PositionId = initialPrepayId, + ValueDate = PreSettleDate, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 700m, TdInterestPrincipal = 700m + }; + var service = new TestableSwapEodService( + new List { td }, + new List { CreateFloatPosition(1, 1000), initialPrepay, realPrepay }, + new List { CreateFloatEodPosition(1, 1000, 1.0020m), prepayEod }, + new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = PreSettleDate } }, + new List { CreateExtend() }, new List()); + + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + var calculatedPrepay = service.LastInterestCalculationPositions + .Single(x => x.id == initialPrepayId); + Assert.AreEqual(700m, calculatedPrepay.InterestPrincipalFix); + Assert.AreEqual(initialPrepayId, calculatedPrepay.id); + } + + [TestMethod] + public void SPC_006_平仓日_预付金日终不得重复扣减实时剩余本金() + { + const long initialPrepayId = 2; + var td = CreateTrade(); + var initialPrepay = new swap_position + { + id = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 1000m, IsInitial = true, Invalid = false, + IsAnnualized = true, + PosiStartDate = SettleDate.AddDays(-1), PosiMatuirityDate = td.ExerciseDate.Value, + InterestSwapInterval = "[]" + }; + var realPrepay = new swap_position + { + id = 3, PositionId = initialPrepayId, SwapTradeId = SwapTradeId, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 700m, IsInitial = false, Invalid = false + }; + var prepayEod = new eod_swap_position + { + id = 200, SwapTradeId = SwapTradeId, PositionId = initialPrepayId, + ValueDate = PreSettleDate, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 1000m, TdInterestPrincipal = 1000m + }; + var closeFlow = CreateCloseFlowEvent(1, 300); + closeFlow.InterestRate = 0.01m; + var service = new TestableSwapEodService( + new List { td }, + new List { CreateFloatPosition(1, 1000), initialPrepay, realPrepay }, + new List { CreateFloatEodPosition(1, 1000, 1.0020m), prepayEod }, + new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = PreSettleDate } }, + new List { CreateExtend() }, + new List { closeFlow }); + + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); + + var persistedPrepay = service.CreatedEodPositions + .Single(x => x.PositionId == initialPrepayId); + Assert.AreEqual(700m, persistedPrepay.InterestPrincipalFix, + "实时腿已经扣减到700,日终不得再次按平仓比例扣减"); + Assert.AreEqual(700m, persistedPrepay.TdInterestPrincipal, + "平仓日预付金计息本金应立即切换为实时剩余本金"); + Assert.AreEqual(700m * 0.01m / 365m, persistedPrepay.TdInterestIncome, + "平仓日新增利息应按实时剩余本金计算"); + } + + [TestMethod] + public void SPC_007_HistoricalReplayUsesAsOfPrincipal() + { + const long originalPositionId = 2; + var original = new swap_position + { + id = originalPositionId, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 10000m + }; + var realtime = new swap_position + { + PositionId = originalPositionId, + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipalFix = 7000m + }; + var close = new swap_flow_event + { + PositionId = originalPositionId, + PositionType = 0, + EventType = (int)SwapEventTypeEnum.平仓, + EventDate = new DateTime(2026, 7, 9), + InterestMode = (int)InterestModeEnum.初始预付金, + InterestPrincipal = 3000m + }; + var floatClose = new swap_flow_event + { + PositionId = 1, + PositionType = 1, + EventType = (int)SwapEventTypeEnum.平仓, + EventDate = new DateTime(2026, 7, 9), + TradingAmount = 3000000m + }; + var originalWithFloat = new List + { + original, + new swap_position { id = 1, PosiDirection = 1, PosiNotionalValue = 10000000m } + }; + + var beforeClose = SwapDealService.ResolveInterestLegPositionsAsOf( + originalWithFloat, new List { realtime }, + new[] { close, floatClose }, new DateTime(2026, 7, 8)) + .Single(x => x.id == originalPositionId); + var onCloseDate = SwapDealService.ResolveInterestLegPositionsAsOf( + originalWithFloat, new List { realtime }, + new[] { close, floatClose }, new DateTime(2026, 7, 9)) + .Single(x => x.id == originalPositionId); + + Assert.AreEqual(10000m, beforeClose.InterestPrincipalFix); + Assert.AreEqual(7000m, onCloseDate.InterestPrincipalFix); + } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index ef329abb..6bb9a47b 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -665,6 +665,58 @@ namespace YLErp.Modules.SwapModule return p; }).ToList(); } + + /// + /// 计算预付金腿当前真实持仓 (当前持仓+未来持仓) + /// + /// + /// + /// + /// + /// + public static List ResolveInterestLegPositionsAsOf( + List origPositions, List realPositions, + IEnumerable completedFlowEvents, DateTime settleDate) + { + realPositions ??= new List(); + var futureFlows = (completedFlowEvents ?? Enumerable.Empty()) + .Where(x => x.EventType == (int)SwapEventTypeEnum.平仓 && x.EventDate > settleDate) + .ToList(); + var originalNotional = origPositions.Where(x => x.PosiDirection > 0) + .Sum(x => x.PosiNotionalValue); + var futureCloseNotional = futureFlows.Where(x => x.PositionType > 0) + .Sum(x => x.TradingAmount); + var hasNotionalFlows = futureCloseNotional > 0 && originalNotional > 0; + var futureClosePrincipal = futureFlows + .Where(x => x.InterestMode == (int)InterestModeEnum.初始预付金 + || x.InterestMode == (int)InterestModeEnum.追加预付金) + .GroupBy(x => x.PositionId) + .ToDictionary(x => x.Key, x => x.Sum(v => v.InterestPrincipal)); + + return origPositions.Where(x => x.PosiDirection == 0).Select(p => + { + if (p.InterestMode == (int)InterestModeEnum.初始预付金 + || p.InterestMode == (int)InterestModeEnum.追加预付金) + { + var realLeg = realPositions.FirstOrDefault(r => r.PositionId == p.id); + if (realLeg != null) + { + var futurePrincipal = hasNotionalFlows + ? p.InterestPrincipalFix * futureCloseNotional / originalNotional + : futureClosePrincipal.TryGetValue(p.id, out var flowPrincipal) ? flowPrincipal : 0m; + var asOfPrincipal = realLeg.InterestPrincipalFix + futurePrincipal; + asOfPrincipal = Math.Min(p.InterestPrincipalFix, Math.Max(0m, asOfPrincipal)); + if (asOfPrincipal != p.InterestPrincipalFix) + { + var clone = p.Clone(); + clone.InterestPrincipalFix = asOfPrincipal; + return clone; + } + } + } + return p; + }).ToList(); + } public static decimal ResolveUnwindPreviousNotional( eod_swap lastEod, diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index cb0458b7..ce7e3807 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -307,6 +307,14 @@ namespace YLErp.Modules.SwapModule return DbContext.swap_flow_event.Where(eventExpression).ToList(); } + protected virtual List FindCompletedFlowEvents(List tradeIds) + { + return DbContext.swap_flow_event + .Where(x => tradeIds.Contains(x.SwapTradeId) + && x.DataState == (int)SwapFlowDateStateEnum.完成) + .ToList(); + } + #endregion /// @@ -352,6 +360,7 @@ namespace YLErp.Modules.SwapModule var tradeRealPositionList = allTradePositionList.Where(t => !t.IsInitial).ToList(); var tradeExtendList = FindTradeExtends(tradeIds); var eodSwapList = FindEodSwapsByDate(preSettleDate); + var completedFlowEvents = FindCompletedFlowEvents(tradeIds); List eventTyps = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; foreach (var td in tradeQueryList) { @@ -364,7 +373,10 @@ namespace YLErp.Modules.SwapModule var realPositions = tradeRealPositionList.Where(s => s.SwapTradeId == td.id); var posiList = positions.Where(x => x.PosiQuantity > 0).ToList(); var realPosiList = realPositions.ToList(); - var interestList = positions.Where(x => x.InterestDirection > 0).ToList(); + var tradeCompletedFlowEvents = completedFlowEvents.Where(x => x.SwapTradeId == td.id).ToList(); + var interestList = SwapDealService.ResolveInterestLegPositionsAsOf( + positions.ToList(), realPosiList, tradeCompletedFlowEvents, settleDate) + .Where(x => x.InterestDirection > 0).ToList(); DateTime posiDate = td.TradeDate.Value;//交易日期 var lastEodSwap = eodSwapList.FirstOrDefault(x => x.SwapTradeId == td.id); //上一交易日无日终归档,且不是交易日期,且当前收盘日期不是交易日期,报错 @@ -1351,8 +1363,9 @@ namespace YLErp.Modules.SwapModule //持仓内容-利息腿 newEodPayPosition.InterestDirection = position.InterestDirection; newEodPayPosition.InterestMode = position.InterestMode; + // ResolveInterestLegPositions 已提供平仓后的实时剩余本金,日终不再重复扣减。 newEodPayPosition.InterestPrincipalFix = position.InterestPrincipalFix; - newEodPayPosition.InterestPrincipalFix *= (1 - closePercent); + // newEodPayPosition.InterestPrincipalFix *= (1 - closePercent); newEodPayPosition.InterestRateDefault = position.InterestRateDefault; newEodPayPosition.InterestSwapInterval = position.InterestSwapInterval; newEodPayPosition.IsAnnualized = position.IsAnnualized; @@ -1364,9 +1377,11 @@ namespace YLErp.Modules.SwapModule newEodPayPosition.interest_rest_days = position.interest_rest_days; newEodPayPosition.interest_rule = position.interest_rule; //利息端估值用信息 - newEodPayPosition.TdInterestPrincipal = position.InterestMode == (int)InterestModeEnum.标的期初全价 - ? posiNotionalValue - : interests.Count > 0 ? interests.First().InterestPrincipal : 0; + newEodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode) + ? position.InterestPrincipalFix + : position.InterestMode == (int)InterestModeEnum.标的期初全价 + ? posiNotionalValue + : interests.Count > 0 ? interests.First().InterestPrincipal : 0; if (interval != null) { newEodPayPosition.TdInterestRate = interval.Rate;