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, 1m, (int)SwapEventTypeEnum.平仓, false, 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 }; } } }