diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs index 907849eb..6fa99b8e 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs @@ -837,6 +837,8 @@ namespace YLErp.Modules.SwapModule AssertDecimal(compoundPrincipalAfterSevenDays, result.TdInterestPrincipal, "复利重置日部分平仓后,EOD 本金必须保留 CalcSwapInterests 计算的 7 天复利本金"); + AssertDecimal(compoundPrincipalAfterSevenDays * FixedRate / AnnualDays, result.TdInterestIncome, + "复利重置日部分平仓后,当日利息必须使用已结转待实现利息的剩余复利本金"); } [TestMethod] @@ -901,6 +903,42 @@ namespace YLErp.Modules.SwapModule "算尾部分平仓:InterestIncomeSum 应=前日待实现+当日新计(含被平仓部分)-当日实现"); } + [DataTestMethod] + [DataRow((int)InterestModeEnum.合约名义本金规模, "300")] + [DataRow((int)InterestModeEnum.标的期初全价, "700")] + public void DI_GLMS_20260421_0004_CalcLastKeepsRemainingCompoundPrincipal( + int interestMode, string calculatedPrincipalText) + { + var service = new StubEodPositionService + { + AutoInterests = new List + { + new() { InterestPrincipal = decimal.Parse(calculatedPrincipalText) } + } + }; + var td = CreateTrade(); + td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "11", + SettlementRules = 1 + }); + var position = CreateInterestPosition(); + position.InterestMode = interestMode; + position.InterestType = (int)InterestTypeEnum.复利; + var previousEod = CreatePreEod(StartDate.AddDays(2), 100m); + previousEod.TdInterestPrincipal = Principal; + var closeFlow = CreateSwapFlowEvent(StartDate.AddDays(3), 50m); + closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; + + var result = service.ExecuteSaveAutoEodWithCloseInterestPosition( + previousEod, position, td, StartDate.AddDays(3), null, + 700m, 0m, new List { closeFlow }, 300m, false); + + AssertDecimal(700m, result.TdInterestPrincipal, + "算尾部分平仓后,EOD 必须按计息模式的返回口径保留剩余70%复利本金"); + } + [TestMethod] public void DI_MANUAL_PARTIAL_CLOSE_NoCalcLastUsesRemainingPrincipalDailyInterest() { diff --git a/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs b/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs new file mode 100644 index 00000000..f10e3439 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs @@ -0,0 +1,358 @@ +using Newtonsoft.Json; +using YLErp.DBModels; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 对话及缺陷表中的部分平仓后最终全平案例。 + /// 使用生产 GetInterests 计算,不连接数据库;数据库数值仅作为冻结输入快照。 + /// + [TestClass] + public class SwapCloseConversationCasesRegressionTest + { + private const int AnnualDays = 365; + private const decimal CentTolerance = 0.015m; + + public sealed class CloseCase + { + public string TradeNumber { get; init; } + public DateTime StartDate { get; init; } + public DateTime CloseDate { get; init; } + public string InterestCalcMode { get; init; } + public int SettlementRules { get; init; } + public int InterestMode { get; init; } + public int InterestType { get; init; } + public int ResetDays { get; init; } + public int InterestRule { get; init; } + public decimal FixedRate { get; init; } + public decimal PreviousPrincipal { get; init; } + public decimal PreviousPendingInterest { get; init; } + public decimal PreviousFloatRate { get; init; } + public decimal CloseFloatRate { get; init; } + public decimal OriginalNotional { get; init; } + public decimal RemainingNotional { get; init; } + public decimal InitialQuantity { get; init; } + public decimal PartialCloseQuantity { get; init; } + public decimal PartialCloseInterest { get; init; } + public decimal ExpectedFinalInterest { get; init; } + + public override string ToString() => TradeNumber; + } + + private sealed class SnapshotSwapDealService : SwapDealService + { + private readonly double _floatRate; + private readonly IReadOnlyDictionary _floatRates; + + public SnapshotSwapDealService(decimal floatRate) + : base(new OptUserInfo(0, nameof(SwapCloseConversationCasesRegressionTest), OptUserFrom.UnitTest)) + { + _floatRate = (double)floatRate; + _floatRates = BuildAprFloatRates(); + } + + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { + if (_floatRates.TryGetValue(valueDate.Date, out rate)) + { + return true; + } + + rate = _floatRate; + return true; + } + + public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) + => 0m; + } + + public static IEnumerable ConversationCases => BuildCases().Select(x => new object[] { x }); + + [DataTestMethod] + [DynamicData(nameof(ConversationCases), DynamicDataSourceType.Property)] + public void FinalCloseMatchesConversationCase(CloseCase closeCase) + { + var trade = CreateTrade(closeCase); + var position = CreatePosition(closeCase); + var previousEod = CreatePreviousEod(closeCase, position); + var service = new SnapshotSwapDealService(closeCase.CloseFloatRate); + + var result = service.GetInterests( + trade, trade.trade_extend, closeCase.CloseDate, closeCase.CloseDate, + new List { previousEod }, new List { position }, + closeCase.RemainingNotional, closeCase.RemainingNotional, 0m, + closeCase.RemainingNotional, 1m, (int)SwapEventTypeEnum.平仓, + false, false, 0m, + closeCase.InterestType == 0 ? closeCase.RemainingNotional : closeCase.OriginalNotional, + add: false, settment: false, newCalcLast: false).Single(); + + AssertAmount(closeCase.ExpectedFinalInterest, result.InterestAmount, + $"{closeCase.TradeNumber} 最终全平利息"); + + if (closeCase.InterestCalcMode.EndsWith("0")) + { + AssertAmount(closeCase.PreviousPendingInterest, result.InterestAmount, + $"{closeCase.TradeNumber} 不算尾时必须带走上日全部待实现利息"); + } + else + { + Assert.AreNotEqual( + Math.Round(closeCase.PreviousPendingInterest, 2, MidpointRounding.AwayFromZero), + Math.Round(result.InterestAmount, 2, MidpointRounding.AwayFromZero), + $"{closeCase.TradeNumber} 算尾时必须包含最终平仓日新增利息"); + } + } + + [DataTestMethod] + [DynamicData(nameof(ConversationCases), DynamicDataSourceType.Property)] + public void PartialCloseSnapshotKeepsOriginalRatioAndRemainingTail(CloseCase closeCase) + { + var closePercentOfOriginal = closeCase.PartialCloseQuantity / closeCase.InitialQuantity; + var expectedPercent = closeCase.TradeNumber.Contains("JIATT") ? 0.4m : 0.3m; + + Assert.AreEqual(expectedPercent, closePercentOfOriginal, + $"{closeCase.TradeNumber} 部分平仓比例必须按期初数量口径记录"); + Assert.AreNotEqual(0m, closeCase.PartialCloseInterest, + $"{closeCase.TradeNumber} 5/11 或 8/4 部分平仓利息快照不得丢失"); + Assert.AreNotEqual(0m, closeCase.PreviousPendingInterest, + $"{closeCase.TradeNumber} 最终平仓前待实现尾差不得提前清零"); + } + + private static trade CreateTrade(CloseCase closeCase) + { + return new trade + { + id = 1, + TradeNumber = closeCase.TradeNumber, + TradeDate = closeCase.StartDate, + StartDate = closeCase.StartDate, + ExerciseDate = closeCase.CloseDate, + TradeStatus = "确认成交", + ValidState = "Valid", + trade_extend = new trade_extend + { + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = closeCase.InterestCalcMode, + SettlementRules = closeCase.SettlementRules + }) + } + }; + } + + private static swap_position CreatePosition(CloseCase closeCase) + { + return new swap_position + { + id = 1, + PositionType = 0, + InterestDirection = 1, + InterestMode = closeCase.InterestMode, + InterestType = closeCase.InterestType, + InterestRateDefault = closeCase.FixedRate, + InterestPrincipalFix = closeCase.OriginalNotional, + PosiStartDate = closeCase.StartDate, + PosiMatuirityDate = closeCase.CloseDate, + IsInitial = true, + Invalid = false, + IsAnnualized = true, + interest_rest_days = closeCase.ResetDays, + interest_rule = closeCase.InterestRule, + FloatRateUnderlyingCode = "FR007", + InterestSwapInterval = JsonConvert.SerializeObject(new List + { + new IntervalModel + { + Date = closeCase.CloseDate, + Rate = closeCase.FixedRate, + Settlement = 0 + } + }) + }; + } + + private static eod_swap_position CreatePreviousEod(CloseCase closeCase, swap_position position) + { + return new eod_swap_position + { + id = 1, + PositionId = position.id, + ValueDate = closeCase.CloseDate.AddDays(-1), + InterestDirection = position.InterestDirection, + InterestMode = position.InterestMode, + InterestType = position.InterestType, + InterestRateDefault = closeCase.FixedRate, + InterestIncomeSum = closeCase.PreviousPendingInterest, + InterestProfitSum = closeCase.PreviousPendingInterest, + TdInterestPrincipal = closeCase.PreviousPrincipal, + PosiNotionalValue = 0m, + FloatRate = closeCase.PreviousFloatRate, + IsAnnualized = true, + interest_rest_days = closeCase.ResetDays, + interest_rule = closeCase.InterestRule + }; + } + + private static void AssertAmount(decimal expected, decimal actual, string message) + { + Assert.IsTrue(Math.Abs(expected - actual) <= CentTolerance, + $"{message}。Expected={expected}, Actual={actual}, Diff={expected - actual}"); + } + + private static IReadOnlyDictionary BuildAprFloatRates() + { + return 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.0130, + [new DateTime(2026, 5, 13)] = 0.0129, + [new DateTime(2026, 5, 14)] = 0.0130, + [new DateTime(2026, 5, 15)] = 0.0130, + [new DateTime(2026, 5, 18)] = 0.0132, + [new DateTime(2026, 5, 19)] = 0.0131 + }; + } + + private static IReadOnlyList BuildCases() + { + var apr21Mode9NoLast = AprCase("", new DateTime(2026, 4, 21), "10", 0, 9, 1, -1, + 0.0025m, 212393195.604981356504m, 260578.522161724795m, 0.0134m, 0.0132m, + 79831.29m, 260578.53m); + var apr21Mode2NoLast = AprCase("", new DateTime(2026, 4, 21), "10", 0, 2, 1, 0, + 0.0025m, 212393594.673529615939m, 259348.391672295294m, 0.0130m, 0.0131m, + 80002.30m, 259348.38m); + var apr21Mode2WithLast = AprCase("", new DateTime(2026, 4, 21), "11", 0, 2, 1, 0, + 0.0025m, 212393594.665105085126m, 259348.383245260196m, 0.0130m, 0.0131m, + 84090.95m, 268428.73m); + var apr22Mode9NoLast = AprCase("", new DateTime(2026, 4, 22), "10", 1, 9, 1, -1, + -0.0210m, 212106644.672742434546m, -118631.263817268568m, 0.0130m, 0.0130m, + -35350.65m, -118631.26m); + var apr22Mode2WithLast = AprCase("", new DateTime(2026, 4, 22), "11", 1, 2, 1, 0, + -0.0210m, 212106237.833444880927m, -119386.717400887353m, 0.0129m, 0.0129m, + -37218.76m, -124093.74m); + var apr21SimpleWithLast = AprCase("", new DateTime(2026, 4, 21), "11", 0, 2, 0, -1, + 0.0025m, 212197382.46m, 260458.629530704663m, 0.0134m, 0.0132m, + 83894.12m, 269586.02m); + + return new List + { + WithTradeNumber(apr21Mode2WithLast, "GLMS-20260421-0007"), + WithTradeNumber(apr21Mode2NoLast, "GLMS-20260421-0005"), + WithTradeNumber(apr21Mode9NoLast, "GLMS-20260421-0001"), + WithTradeNumber(apr22Mode2WithLast, "GLMS-20260421-0008"), + WithTradeNumber(apr21SimpleWithLast, "GLMS-20260421-0011"), + WithTradeNumber(apr21Mode2WithLast, "GLMS-MARSK-20260421-FICC-01-180205IB"), + WithTradeNumber(apr21Mode9NoLast, "GLMS-MARSK-20260421-FICC-02-180205IB"), + WithTradeNumber(apr22Mode9NoLast, "GLMS-MARSK-20260421-FICC-03-180205IB"), + WithTradeNumber(apr22Mode2WithLast, "GLMS-MARSK-20260421-FICC-04-180205IB"), + WithTradeNumber(apr21SimpleWithLast, "GLMS-MARSK-20260421-FICC-05-180205IB"), + JiattCase("GLMS-JIATT-20260805-FICC-01-2180120IB", 30041492.070122881942m, + 10019.043756537721m, 2970.02m, 10019.04105m), + JiattCase("GLMS-JIATT-20260727-FICC-02-2180120IB", 30044833.3381m, + 13360.932596m, 5197.53m, 13360.93051m) + }; + } + + private static CloseCase AprCase(string tradeNumber, DateTime startDate, string calcMode, + int settlementRules, int interestMode, int interestType, int interestRule, + decimal fixedRate, decimal previousPrincipal, decimal previousPending, + decimal previousFloatRate, decimal closeFloatRate, decimal partialInterest, + decimal expectedFinal) + { + return new CloseCase + { + TradeNumber = tradeNumber, + StartDate = startDate, + CloseDate = new DateTime(2026, 5, 19), + InterestCalcMode = calcMode, + SettlementRules = settlementRules, + InterestMode = interestMode, + InterestType = interestType, + ResetDays = 7, + InterestRule = interestRule, + FixedRate = fixedRate, + PreviousPrincipal = previousPrincipal, + PreviousPendingInterest = previousPending, + PreviousFloatRate = previousFloatRate, + CloseFloatRate = closeFloatRate, + OriginalNotional = 303139117.80m, + RemainingNotional = 212197382.46m, + InitialQuantity = 300000000m, + PartialCloseQuantity = 90000000m, + PartialCloseInterest = partialInterest, + ExpectedFinalInterest = expectedFinal + }; + } + + private static CloseCase JiattCase(string tradeNumber, decimal remainingPrincipal, + decimal previousPending, decimal partialInterest, decimal expectedFinal) + { + return new CloseCase + { + TradeNumber = tradeNumber, + StartDate = new DateTime(2026, 7, 28), + CloseDate = new DateTime(2026, 8, 7), + InterestCalcMode = "10", + SettlementRules = 0, + InterestMode = 9, + InterestType = 1, + ResetDays = 7, + InterestRule = -1, + FixedRate = 0.001234m, + PreviousPrincipal = remainingPrincipal, + PreviousPendingInterest = previousPending, + PreviousFloatRate = 0.0213m, + CloseFloatRate = 0.0213m, + OriginalNotional = 50061728.39m, + RemainingNotional = remainingPrincipal, + InitialQuantity = 50000000m, + PartialCloseQuantity = 20000000m, + PartialCloseInterest = partialInterest, + ExpectedFinalInterest = expectedFinal + }; + } + + private static CloseCase WithTradeNumber(CloseCase source, string tradeNumber) + { + return new CloseCase + { + TradeNumber = tradeNumber, + StartDate = source.StartDate, + CloseDate = source.CloseDate, + InterestCalcMode = source.InterestCalcMode, + SettlementRules = source.SettlementRules, + InterestMode = source.InterestMode, + InterestType = source.InterestType, + ResetDays = source.ResetDays, + InterestRule = source.InterestRule, + FixedRate = source.FixedRate, + PreviousPrincipal = source.PreviousPrincipal, + PreviousPendingInterest = source.PreviousPendingInterest, + PreviousFloatRate = source.PreviousFloatRate, + CloseFloatRate = source.CloseFloatRate, + OriginalNotional = source.OriginalNotional, + RemainingNotional = source.RemainingNotional, + InitialQuantity = source.InitialQuantity, + PartialCloseQuantity = source.PartialCloseQuantity, + PartialCloseInterest = source.PartialCloseInterest, + ExpectedFinalInterest = source.ExpectedFinalInterest + }; + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index e7c07412..04505fda 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1422,30 +1422,35 @@ namespace YLErp.Modules.SwapModule || position.InterestMode == (int)InterestModeEnum.标的期初全价)) { // 模式2(合约名义本金规模)和模式9(标的期初全价)都以名义本金 - // 作为复利基数,适用同一部分平仓递推;算尾用平仓前全额当日利息 - // 再扣实际结算,不算尾只计剩余本金,避免已平部分利息进入后续复利。 + // 作为复利基数;算尾用平仓前全额当日利息再扣实际结算, + // 不算尾只计剩余本金,避免已平部分利息进入后续复利。 var fullPrincipal = lastTdInterestPrincipal > 0m ? lastTdInterestPrincipal : oriPosiNotionalValue; // 当日计提按平仓前全额动态本金;跨日携带必须只留剩余仓位。 - // calcLast=true 返回的是本次已平部分动态本金,按平仓比例反推全额后取剩余; + // calcLast=true 时,模式2返回本次已平部分本金,需反推剩余本金; + // 模式9返回的已是剩余本金,不能再次按比例放大(GLMS-20260421-0004)。 // calcLast=false 快速路径返回上一 EOD 全额本金,保留原剩余比例缩放。 var usesFullPreviousEodPrincipal = !calcLast && hasPreviousEod && (valueDate - eodPayPosition.ValueDate).Days == 1 && (valueDate - position.PosiStartDate).Days % (position.interest_rest_days ?? 1) != 0; - if (calcLast) + if (calcLast + && position.InterestMode == (int)InterestModeEnum.合约名义本金规模) { - // calcLast=true 的 InterestPrincipal 是已平部分,不是跨日剩余本金。 + // 模式2的 InterestPrincipal 是已平部分,不是跨日剩余本金。 newEodPayPosition.TdInterestPrincipal *= (1m - closePercent) / closePercent; } else if (usesFullPreviousEodPrincipal) { newEodPayPosition.TdInterestPrincipal *= 1m - closePercent; } + // 不算尾时,TdInterestPrincipal 已由计息器完成重置日待实现利息结转, + // 并在非重置日分支按剩余仓位调整;若再次用上日本金乘剩余比例, + // 会漏掉重置后已并入本金的待实现利息(如 2026-08-04 两笔 JIATT 交易)。 var accrualPrincipal = calcLast ? fullPrincipal - : fullPrincipal * (1m - closePercent); + : newEodPayPosition.TdInterestPrincipal; newEodPayPosition.TdInterestIncome = accrualPrincipal * (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate); if (position.IsAnnualized)