using Newtonsoft.Json; using YLErp.DBModels; using YLErp.DBModels.Enums; namespace YLErp.Modules.SwapModule { /// /// DealInterests 利息腿归档 - 合成单元测试(内存,不连库) /// ============================================================================ /// 目标:验证收盘时利息腿 eod 的字段计算,覆盖三个分支: /// ① 手动互换分支 SaveEodInterestPosition(我们修复 InterestIncomeSum 归零的核心) /// ② 普通日分支 SaveEodInterestPositionCopy(InterestIncomeSum 每日递增) /// ③ 多日守恒(半平后多日再全平,利息一致性) /// /// 模仿 GetInterestsUnitTest_T0 的风格: /// - 继承生产类,override 虚方法替换 DB 调用 /// - 内存构造 trade/position/eod/flowEvent 数据 /// - 断言业务期望值(独立计算,非循环论证) /// ============================================================================ [TestClass] public class DealInterestsScenarioTest { #region 测试常量 private const decimal Principal = 1000m; private const decimal FixedRate = 0.01m; private const int AnnualDays = 365; private static readonly DateTime StartDate = new(2026, 4, 27); private static readonly DateTime ExerciseDate = new(2027, 4, 27); /// 每天利息(固定利率,算头不算尾,年化365天) private static decimal DailyInterest => Math.Round(Principal * FixedRate / AnnualDays, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); #endregion #region Stub:内存 SwapEodPositionService /// /// 测试用子类:override 虚方法,把 DB 调用替换为内存操作。 /// - PersistEodSwapPosition:收集到列表而非写库 /// - GetCurrencyRate:返回 1.0(本币) /// private sealed class StubEodPositionService : TestableSwapEodPositionService { /// /// 自动互换场景可注入固定流水,避免为了验证结算边界而依赖真实计息公式。 /// 未赋值时仍走生产使用的真实 GetInterests 计算。 /// public List AutoInterests { get; set; } public SwapDealService DealService { get; set; } public eod_swap_position LastInterestCalculationEodPosition { get; private set; } public int LastEventType { get; set; } public StubEodPositionService() : base(nameof(DealInterestsScenarioTest)) { } // override CalcSwapInterests:用真实 SwapDealService 算(固定利率不需 mock 浮动利率) // 生产代码默认实现也是 new SwapDealService(this).GetInterests(...),这里保持一致 // 但 SwapDealService 内部 TryGetFloatRate 会连库——固定利率(FloatRateUnderlyingCode=null)不会触发 protected override List CalcSwapInterests( trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, List eodPositions, List positions, decimal posiNotionalValue, decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { LastInterestCalculationEodPosition = eodPositions.SingleOrDefault(); LastEventType = eventType; if (AutoInterests != null) { return AutoInterests; } return (DealService ?? new SwapDealService(this)).GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, closePosiNotionalValue, closePrecent, eventType, tdClose, orginPv, add, settment, newCalcLast, closeList); } // public 包装:让测试能调用 protected 方法 public eod_swap_position ExecuteSaveEodInterestPosition( eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition, swap_position position, trade td, DateTime valueDate, List flowEvents) { SaveEodInterestPosition(eodPayPosition, newEodPayPosition, position, td, valueDate, flowEvents); return PersistedPositions.LastOrDefault(); } // public 包装:验证自动互换时的“高精度应结 -> 两位实际结算 -> 待实现尾差”链路。 public eod_swap_position ExecuteSaveAutoEodInterestPosition( eod_swap_position eodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, eod_swap lastEodSwap = null, decimal posiLongNotional = DealInterestsScenarioTest.Principal, decimal orginPv = DealInterestsScenarioTest.Principal) { SaveAutoEodInterestPosition(eodPayPosition, null, position, td, valueDate, interval, lastEodSwap, posiLongNotional + 0m, 1m, orginPv); return PersistedPositions.LastOrDefault(); } public eod_swap_position ExecuteSaveAutoEodWithCloseInterestPosition( eod_swap_position eodPayPosition, swap_position position, trade td, DateTime valueDate, IntervalModel interval, decimal posiLongNotional, decimal posiShortNotional, List flowEvents, decimal closeNotional, bool autoSwap) { SaveAutoEodWithCloseInterestPosition(eodPayPosition, null, position, td, valueDate, interval, posiLongNotional + posiShortNotional, flowEvents, closeNotional, autoSwap, 1m, DealInterestsScenarioTest.Principal); return PersistedPositions.LastOrDefault(); } public eod_swap_position ExecuteSaveEodInterestPositionCopy( eod_swap_position eodPayPosition, swap_position position, trade td, DateTime valueDate, decimal posiLongNotional, decimal posiShortNotional, decimal grossPrice, decimal orginPv) { SaveEodInterestPositionCopy(eodPayPosition, null, valueDate, td, position, null, false, posiLongNotional + posiShortNotional, grossPrice, orginPv); return PersistedPositions.LastOrDefault(); } // public 包装:直接调用 protected virtual DealInterests(已改为 virtual,无需反射) public void ExecuteDealInterests( List interestList, List eodPositions, DateTime settleDate, trade td, List flowEvents, decimal posiLongNational, decimal posiShortNational, decimal closeNational, decimal grossPrice, decimal orginPv) { DealInterests(interestList, eodPositions, new List(), settleDate, td, flowEvents, new List(), null, posiLongNational + posiShortNational, closeNational, grossPrice, orginPv); } } 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 数据构建器 private static trade CreateTrade() { return new trade { id = 1, TradeNumber = "UT-DEAL-INT-001", ClientId = 999998, TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid", StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY", trade_extend = new trade_extend { TradeId = 1, ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson { AnnualDays = AnnualDays, InterestCalcMode = "10", // 算头不算尾 SettlementRules = 0 }) } }; } private static swap_position CreateInterestPosition() { return new swap_position { id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = FixedRate, InterestPrincipalFix = Principal, PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate, IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, interest_rest_days = 1, interest_rule = 0, FloatRateUnderlyingCode = null, // 固定利率,不需要浮动 InterestSwapInterval = JsonConvert.SerializeObject(new List { new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 } }) }; } /// 创建前一日 eod(模拟"昨天收盘后的状态") private static eod_swap_position CreatePreEod(DateTime valueDate, decimal interestProfitSum, decimal realizedInterest = 0m) { return new eod_swap_position { id = 100, SwapTradeId = 1, PositionId = 1001, ValueDate = valueDate, ClientId = 999998, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价, InterestProfitSum = interestProfitSum, InterestIncomeSum = interestProfitSum, RealizedInterest = realizedInterest, InterestRateDefault = FixedRate, TdInterestPrincipal = Principal, PosiNotionalValue = Principal, InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, interest_rest_days = 1, FloatRate = 0m }; } /// 创建互换 flow_event(模拟"当天做了收益结算") private static swap_flow_event CreateSwapFlowEvent(DateTime eventDate, decimal interestAmount) { return new swap_flow_event { id = 2001, SwapTradeId = 1, EventType = (int)SwapFlowEventTypeEnum.互换, EventDate = eventDate, UnwindDate = eventDate, PositionId = 1001, InterestDirection = (int)SwapDirectionEnum.收取, InterestAmount = interestAmount, InterestClosePnL = interestAmount, // 收取方向,两者相等 InterestRate = FixedRate, InterestMode = (int)InterestModeEnum.标的期初全价, InterestPrincipal = Principal, FloatRate = 0m, DataState = (int)SwapFlowDateStateEnum.完成 }; } /// /// 创建自动互换利息流水。InterestAmount 是高精度应结,生产入口负责将实际结算收敛到两位。 /// private static swap_flow_event CreateAutoSwapFlowEvent(DateTime eventDate, decimal interestAmount) { return new swap_flow_event { id = 2002, SwapTradeId = 1, EventType = (int)SwapFlowEventTypeEnum.自动互换, EventDate = eventDate, UnwindDate = eventDate, PositionId = 1001, InterestDirection = (int)SwapDirectionEnum.收取, InterestAmount = interestAmount, TdInterestAmount = interestAmount, InterestClosePnL = interestAmount, InterestRate = FixedRate, InterestMode = (int)InterestModeEnum.标的期初全价, InterestPrincipal = Principal, FloatRate = 0m, DataState = (int)SwapFlowDateStateEnum.完成 }; } private static void AssertDecimal(decimal expected, decimal actual, string message = "") { var tolerance = 1m / (decimal)Math.Pow(10, ConsGlobal.PriceRound - 2); Assert.IsTrue(Math.Abs(expected - actual) <= tolerance, $"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}"); } public sealed class ExcelScenario4Case { public string TradeNumber { get; init; } public DateTime StartDate { get; init; } public int SettlementRules { get; init; } public int InterestMode { get; init; } public int InterestType { get; init; } public string InterestCalcMode { get; init; } public int InterestRule { get; init; } public decimal FixedRate { get; init; } public decimal ExpectedPartialInterest { get; init; } public decimal ExpectedFinalInterest { get; init; } public override string ToString() => TradeNumber; } public static IEnumerable ExcelScenario4Cases => new List { ExcelCase("GLMS-20260421-0008", new DateTime(2026, 4, 22), 1, 2, 1, "11", 0, -0.021m, -37218.76m, -124093.74m), ExcelCase("GLMS-20260421-0007", new DateTime(2026, 4, 21), 0, 2, 1, "11", 0, 0.0025m, 84090.95m, 268428.73m), ExcelCase("GLMS-20260421-0006", new DateTime(2026, 4, 22), 1, 9, 1, "10", 0, -0.021m, -35375.54m, -119386.71m), ExcelCase("GLMS-20260421-0005", new DateTime(2026, 4, 21), 0, 2, 1, "10", 0, 0.0025m, 80002.31m, 259348.38m), ExcelCase("GLMS-20260421-0004", new DateTime(2026, 4, 22), 1, 9, 1, "11", -1, -0.021m, -37119.14m, -123280.17m), ExcelCase("GLMS-20260421-0003", new DateTime(2026, 4, 21), 0, 2, 1, "11", -1, 0.0025m, 83919.92m, 269717.13m), ExcelCase("GLMS-20260421-0002", new DateTime(2026, 4, 22), 1, 9, 1, "10", -1, -0.021m, -35350.65m, -118631.26m), ExcelCase("GLMS-20260421-0001", new DateTime(2026, 4, 21), 0, 9, 1, "10", -1, 0.0025m, 79831.29m, 260578.53m), ExcelCase("GLMS-20260421-0012", new DateTime(2026, 4, 22), 1, 9, 0, "11", -1, -0.021m, -37124.16m, -123307.03m), ExcelCase("GLMS-20260421-0011", new DateTime(2026, 4, 21), 0, 2, 0, "11", -1, 0.0025m, 83894.12m, 269586.02m), ExcelCase("GLMS-20260421-0010", new DateTime(2026, 4, 22), 1, 9, 0, "10", 0, -0.021m, -35380.07m, -119411.90m), ExcelCase("GLMS-20260421-0009", new DateTime(2026, 4, 21), 0, 9, 0, "10", -1, 0.0025m, 79807.97m, 260458.63m) }.Select(x => new object[] { x }); private static ExcelScenario4Case ExcelCase(string tradeNumber, DateTime startDate, int settlementRules, int interestMode, int interestType, string interestCalcMode, int interestRule, decimal fixedRate, decimal expectedPartialInterest, decimal expectedFinalInterest) { return new ExcelScenario4Case { TradeNumber = tradeNumber, StartDate = startDate, SettlementRules = settlementRules, InterestMode = interestMode, InterestType = interestType, InterestCalcMode = interestCalcMode, InterestRule = interestRule, FixedRate = fixedRate, ExpectedPartialInterest = expectedPartialInterest, ExpectedFinalInterest = expectedFinalInterest }; } private static IReadOnlyDictionary CreateExcelScenario4Fr007Rates() { 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, 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 }; } #endregion // ================================================================ // 场景1:互换结清后 InterestIncomeSum 应归零(cs:837 修复验证) // ================================================================ #region 场景1:互换结清后 InterestIncomeSum 归零 /// /// [DI_SWAP_ZERO_001] 互换结清-攒了N天利息后全额互换结算,待实现应归零 /// --------------------------------------------------------------- /// 起息日4/27,攒到5/10(13天),InterestProfitSum≈13天利息。 /// 5/10做互换结算,flow_event.InterestAmount=13天利息。 /// 收盘后 InterestIncomeSum 应≈0(全部已实现)。 /// --------------------------------------------------------------- /// [TestMethod] public void DI_SWAP_ZERO_001_互换结清后待实现归零() { var service = new StubEodPositionService(); var td = CreateTrade(); var position = CreateInterestPosition(); var settleDate = new DateTime(2026, 5, 10); // 攒了13天利息(4/27~5/9,算头不算尾) int days = (settleDate - StartDate).Days; decimal accumulatedInterest = Math.Round(Principal * FixedRate * days / AnnualDays, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); var preEod = CreatePreEod(settleDate.AddDays(-1), accumulatedInterest); // 当天做了互换结算,利息=攒的全部 var swapEvent = CreateSwapFlowEvent(settleDate, accumulatedInterest); // 执行互换分支 var result = service.ExecuteSaveEodInterestPosition(preEod, null, position, td, settleDate, new List { swapEvent }); // 核心断言:InterestIncomeSum = pre + 当天新计(TdInterestIncome) - 实现(TdCloseInterest) // 互换把攒的13天全付了(TdCloseInterest=accumulatedInterest),但当天又产生1天新计(TdInterestIncome) // 所以 InterestIncomeSum 应 ≈ 1天新计利息(而非严格0) // 公式(cs:869): pre.InterestIncomeSum + TdInterestIncome - TdCloseInterest decimal expectedTdInterestIncome = Math.Round(Principal * FixedRate / AnnualDays, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); AssertDecimal(expectedTdInterestIncome, result.InterestIncomeSum, $"互换结清后 InterestIncomeSum 应=当天新计利息({expectedTdInterestIncome:F6})," + $"而非攒的全程({accumulatedInterest:F6})"); // TdCloseInterest 应=互换实现的利息 AssertDecimal(accumulatedInterest, result.TdCloseInterest, "TdCloseInterest 应=互换实现的利息"); // RealizedInterest 应累加(preEod.RealizedInterest + TdCloseInterest * ratio) // 收取方向 ratio=1 AssertDecimal(accumulatedInterest, result.RealizedInterest, "RealizedInterest 应累加已实现利息"); Console.WriteLine($"攒了{days}天利息={accumulatedInterest:F6}"); Console.WriteLine($"互换结清后 InterestIncomeSum={result.InterestIncomeSum:F6}(应≈0)✅"); Console.WriteLine($"TdCloseInterest={result.TdCloseInterest:F6} RealizedInterest={result.RealizedInterest:F6}"); } /// /// [DI_SWAP_ZERO_002] 互换结清后 InterestIncomeSum 不为负(防多扣) /// --------------------------------------------------------------- /// 验证:待实现=0(已结清)时,TdCloseInterest=当天新计,InterestIncomeSum 应=0。 /// 公式: 0 + 当天新计 - 当天新计 = 0。如果公式有误会变成负数。 /// --------------------------------------------------------------- /// [TestMethod] public void DI_SWAP_ZERO_002_互换结清后待实现不为负() { var service = new StubEodPositionService(); var td = CreateTrade(); var position = CreateInterestPosition(); var swapDate = new DateTime(2026, 5, 10); // 已结清状态:待实现=0 var postSwapEod = CreatePreEod(swapDate.AddDays(-1), 0m, 0m); // 互换只结算当天新计(InterestAmount=当天新计利息) decimal dailyInc = Math.Round(Principal * FixedRate / AnnualDays, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); var swapEvent = CreateSwapFlowEvent(swapDate, dailyInc); var result = service.ExecuteSaveEodInterestPosition(postSwapEod, null, position, td, swapDate, new List { swapEvent }); // 公式: 0(待实现) + dailyInc(新计) - dailyInc(实现) = 0 AssertDecimal(0m, result.InterestIncomeSum, "待实现=0+当天新计-当天新计应=0,不应为负"); Console.WriteLine($"已结清后再互换(只结算当天新计):InterestIncomeSum={result.InterestIncomeSum:F6} = 0 ✅"); } #endregion // ================================================================ // 场景2:DealInterests 分支选择逻辑验证 // ================================================================ #region 场景2:分支选择 /// /// [DI_BRANCH_001] 普通日(无互换无平仓无观察日)→ 走 copy 分支 /// --------------------------------------------------------------- /// flowEvents 为空,observationInterval=null,hasSwap=false,hasClose=false /// → 应走 SaveEodInterestPositionCopy(cs:338) /// --------------------------------------------------------------- /// /// [DI_BRANCH_001] 普通日收盘归档:InterestIncomeSum 每天递增1天利息 /// --------------------------------------------------------------- /// 前日待实现=1天利息,今日收盘(无互换无平仓),应变成2天利息。 /// 验证 copy 分支(SaveEodInterestPositionCopy)的 InterestIncomeSum 公式。 /// --------------------------------------------------------------- /// [TestMethod] public void DI_BRANCH_001_普通日归档待实现递增() { var service = new StubEodPositionService(); var td = CreateTrade(); var position = CreateInterestPosition(); var settleDate = new DateTime(2026, 4, 28); // 第2天 var preEod = CreatePreEod(settleDate.AddDays(-1), DailyInterest); // 前日=1天利息 // 普通日:无互换无平仓,InterestSwapInterval=null(当天非观察日) position.InterestSwapInterval = null; service.ExecuteDealInterests( new List { position }, new List { preEod }, settleDate, td, new List(), Principal, 0m, 0m, 1m, Principal); Assert.IsTrue(service.PersistedPositions.Count > 0, "应生成eod"); var result = service.PersistedPositions[0]; // 普通日:InterestIncomeSum 应 = 前日 + 当天新计 = 1天 + 1天 = 2天 AssertDecimal(DailyInterest * 2, result.InterestIncomeSum, $"普通日后 InterestIncomeSum 应=2天利息({DailyInterest * 2:F6})"); Console.WriteLine($"普通日归档:InterestIncomeSum={result.InterestIncomeSum:F6} = 2×{DailyInterest:F6} ✅"); } /// /// [DI_BRANCH_002] 互换日(hasSwap=true)→ 走 SaveEodInterestPosition 分支 /// --------------------------------------------------------------- /// flowEvents 含 EventType=互换,hasSwap=true /// → 应走 SaveEodInterestPosition(cs:330) /// → 验证 PersistEodSwapPosition 被调用(生成了 eod) /// --------------------------------------------------------------- /// [TestMethod] public void DI_BRANCH_002_互换日走SaveEodInterestPosition分支() { var service = new StubEodPositionService(); var td = CreateTrade(); var position = CreateInterestPosition(); var settleDate = new DateTime(2026, 5, 10); var preEod = CreatePreEod(settleDate.AddDays(-1), DailyInterest * 13); // 互换事件 var swapEvent = CreateSwapFlowEvent(settleDate, DailyInterest * 13); position.InterestSwapInterval = null; var interestList = new List { position }; var eodPositions = new List { preEod }; service.ExecuteDealInterests(interestList, eodPositions, settleDate, td, new List { swapEvent }, Principal, 0m, 0m, 1m, Principal); // 互换分支应生成1条 eod Assert.AreEqual(1, service.PersistedPositions.Count, "互换分支应生成1条eod"); var result = service.PersistedPositions[0]; // InterestIncomeSum = pre + 当天新计 - 实现 ≈ 当天新计(攒的全付了) AssertDecimal(DailyInterest, result.InterestIncomeSum, "互换结清后待实现≈当天新计利息"); Console.WriteLine($"互换日分支执行,InterestIncomeSum={result.InterestIncomeSum:F6} ≈ 当天新计({DailyInterest:F6}) ✅"); } #endregion // ================================================================ #region 场景2补充:剩余3分支路由断言(经真实 DealInterests 路由器) /// /// [DI_BRANCH_003] 观察日无平仓(observationDay!=null, hasSwap=false, hasClose=false) /// -> 走 SaveAutoEodInterestPosition(autoSwap 路径)。 /// 守卫:CalcSwapInterests 收到 eventType=自动互换、tdClose=false。 /// 补盖 TEST-MATRIX §6 的 AutoSettle 分支。 /// [TestMethod] public void DI_BRANCH_003_观察日自动结息走SaveAutoEodInterestPosition() { var service = new StubEodPositionService(); var td = CreateTrade(); var position = CreateInterestPosition(); var settleDate = new DateTime(2026, 5, 10); var preEod = CreatePreEod(settleDate.AddDays(-1), DailyInterest); position.InterestSwapInterval = JsonConvert.SerializeObject(new List { new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 } }); service.ExecuteDealInterests( new List { position }, new List { preEod }, settleDate, td, new List(), Principal, 0m, 0m, 1m, Principal); Assert.IsTrue(service.PersistedPositions.Count > 0, "观察日应生成eod"); Assert.AreEqual((int)SwapEventTypeEnum.自动互换, service.LastEventType, "观察日自动结息 eventType 应为自动互换"); // 观察日自动结息走 SaveAutoEodInterestPosition(无平仓):TdCloseInterest 仅为当日利息,不被平仓放大 Assert.IsTrue(service.PersistedPositions[0].TdCloseInterest < 0.1m, "观察日自动结息无平仓,TdCloseInterest 应仅为当日利息(<0.1),不应含平仓利息"); Console.WriteLine("观察日自动结息分支 ✅ eventType=自动互换, tdClose=false"); } /// /// [DI_BRANCH_004] 观察日+平仓(observationDay!=null, hasClose=true) /// -> 走 SaveAutoEodWithCloseInterestPosition(autoSwap:true)。 /// 守卫:CalcSwapInterests 收到 eventType=自动互换、tdClose=true。 /// 补盖 TEST-MATRIX §6 最弱格子(autoSwap=true 部分平仓)。 /// [TestMethod] public void DI_BRANCH_004_观察日平仓走WithClose_autoSwapTrue() { var service = new StubEodPositionService(); var td = CreateTrade(); var position = CreateInterestPosition(); var settleDate = new DateTime(2026, 5, 10); var preEod = CreatePreEod(settleDate.AddDays(-1), DailyInterest * 13); position.InterestSwapInterval = JsonConvert.SerializeObject(new List { new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 } }); var closeEvent = CreateSwapFlowEvent(settleDate, DailyInterest * 13); closeEvent.EventType = (int)SwapFlowEventTypeEnum.平仓; service.ExecuteDealInterests( new List { position }, new List { preEod }, settleDate, td, new List { closeEvent }, Principal, 0m, 300m, 1m, Principal); Assert.IsTrue(service.PersistedPositions.Count > 0, "观察日+平仓应生成eod"); Assert.AreEqual((int)SwapEventTypeEnum.自动互换, service.LastEventType, "autoSwap:true -> eventType 应为自动互换"); // 含平仓:TdCloseInterest 应明显大于纯当日利息(实测约 0.38) Assert.IsTrue(service.PersistedPositions[0].TdCloseInterest > 0.1m, "观察日+平仓 TdCloseInterest 应含平仓利息(>0.1)"); Console.WriteLine("观察日+平仓分支 ✅ eventType=自动互换, TdCloseInterest>0.1 (autoSwap:true)"); } /// /// [DI_BRANCH_005] 非观察日+平仓(observationDay==null, hasClose=true, hasSwap=false) /// -> 走 SaveAutoEodWithCloseInterestPosition(autoSwap:false)。 /// 守卫:CalcSwapInterests 收到 eventType=平仓、tdClose=true。 /// 补盖 TEST-MATRIX §6 的 CloseOnly 分支。 /// [TestMethod] public void DI_BRANCH_005_纯平仓走WithClose_autoSwapFalse() { var service = new StubEodPositionService(); var td = CreateTrade(); var position = CreateInterestPosition(); var settleDate = new DateTime(2026, 5, 10); var preEod = CreatePreEod(settleDate.AddDays(-1), DailyInterest * 13); position.InterestSwapInterval = null; var closeEvent = CreateSwapFlowEvent(settleDate, DailyInterest * 13); closeEvent.EventType = (int)SwapFlowEventTypeEnum.平仓; service.ExecuteDealInterests( new List { position }, new List { preEod }, settleDate, td, new List { closeEvent }, Principal, 0m, 300m, 1m, Principal); Assert.IsTrue(service.PersistedPositions.Count > 0, "纯平仓应生成eod"); Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.LastEventType, "autoSwap:false -> eventType 应为平仓"); Assert.IsTrue(service.PersistedPositions[0].TdCloseInterest > 0.1m, "纯平仓 TdCloseInterest 应含平仓利息(>0.1)"); Console.WriteLine("纯平仓分支 ✅ eventType=平仓, TdCloseInterest>0.1 (autoSwap:false)"); } #endregion // 场景3:多日守恒——连续收盘归档,InterestIncomeSum 应线性递增 // ================================================================ #region 场景3:多日连续归档 /// /// [DI_MULTI_001] 连续5天普通日收盘归档,InterestIncomeSum 每天递增1天利息 /// --------------------------------------------------------------- /// 从4/27(首日)开始,连续收盘到5/1,验证 InterestIncomeSum 线性递增。 /// 每天收盘后 InterestIncomeSum 应 = 天数 × DailyInterest。 /// --------------------------------------------------------------- /// [TestMethod] public void DI_MULTI_001_连续5天归档待实现线性递增() { var td = CreateTrade(); var position = CreateInterestPosition(); position.InterestSwapInterval = null; // 无观察日 decimal runningIncomeSum = 0m; var runningDate = StartDate; for (int day = 0; day < 5; day++) { var service = new StubEodPositionService(); var preEod = CreatePreEod(runningDate.AddDays(-1), runningIncomeSum); service.ExecuteDealInterests( new List { position }, new List { preEod }, runningDate, td, new List(), Principal, 0m, 0m, 1m, Principal); Assert.IsTrue(service.PersistedPositions.Count > 0, $"第{day + 1}天应生成eod"); var result = service.PersistedPositions[0]; // 首日 InterestIncomeSum = 1天利息,后续每天+1天利息 decimal expected = DailyInterest * (day + 1); AssertDecimal(expected, result.InterestIncomeSum, $"第{day + 1}天 InterestIncomeSum 应={(day + 1)}天利息"); runningIncomeSum = result.InterestIncomeSum; runningDate = runningDate.AddDays(1); } Console.WriteLine($"连续5天归档:InterestIncomeSum 从0递增到{runningIncomeSum:F6} = 5×{DailyInterest:F6} ✅"); } // ================================================================ // 场景4:互换→收盘→再攒→再互换 守恒验证 // ================================================================ #region 场景4:多次互换结算守恒 /// /// [DI_SWAP_MULTI_001] 攒10天→互换结清→再攒5天→再互换结清 /// --------------------------------------------------------------- /// 验证:第一次互换后 InterestIncomeSum≈当天新计(攒的10天付了), /// 再攒5天后 InterestIncomeSum≈6天(5天新攒+1天当天新计), /// 第二次互换后 InterestIncomeSum≈当天新计(攒的6天又付了)。 /// /// 守恒约束:两次互换结算的 TdCloseInterest 之和 = 全程利息(15天+2天新计)。 /// [TestMethod] public void DI_SWAP_MULTI_001_多次互换结算守恒() { var td = CreateTrade(); var position = CreateInterestPosition(); // --- Phase 1: 攒10天(4/27~5/6),到5/6 --- var date10 = StartDate.AddDays(10); // 5/7 decimal sum10days = Math.Round(Principal * FixedRate * 10 / AnnualDays, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); var preEod10 = CreatePreEod(date10.AddDays(-1), sum10days - DailyInterest); // 前日=9天 // 当天新计让它到10天 var svc1 = new StubEodPositionService(); svc1.ExecuteDealInterests(new List { position }, new List { preEod10 }, date10, td, new List(), Principal, 0m, 0m, 1m, Principal); var eod10days = svc1.PersistedPositions[0]; AssertDecimal(sum10days, eod10days.InterestIncomeSum, "10天后待实现应=10天利息"); Console.WriteLine($"Phase1: 攒10天 InterestIncomeSum={eod10days.InterestIncomeSum:F6}"); // --- Phase 2: 5/7 互换结清 --- var swapDate1 = date10; // 同天互换 var svc2 = new StubEodPositionService(); var swapEvt1 = CreateSwapFlowEvent(swapDate1, sum10days); var swapResult1 = svc2.ExecuteSaveEodInterestPosition( eod10days, null, position, td, swapDate1, new List { swapEvt1 }); // 互换后待实现≈当天新计(攒的10天付了,但当天又产生1天新计) decimal dailyInc = Math.Round(Principal * FixedRate / AnnualDays, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); AssertDecimal(dailyInc, swapResult1.InterestIncomeSum, "第一次互换后待实现≈当天新计"); decimal firstRealized = swapResult1.TdCloseInterest; Console.WriteLine($"Phase2: 第一次互换 TdCloseInterest={firstRealized:F6}, 待实现={swapResult1.InterestIncomeSum:F6}"); // --- Phase 3: 再攒5天 --- decimal runningSum = swapResult1.InterestIncomeSum; var runningDate = swapDate1.AddDays(1); for (int i = 0; i < 5; i++) { var svc = new StubEodPositionService(); var preEod = CreatePreEod(runningDate.AddDays(-1), runningSum); // 需要 preEod.RealizedInterest 累积 preEod.RealizedInterest = swapResult1.RealizedInterest; svc.ExecuteDealInterests(new List { position }, new List { preEod }, runningDate, td, new List(), Principal, 0m, 0m, 1m, Principal); runningSum = svc.PersistedPositions[0].InterestIncomeSum; runningDate = runningDate.AddDays(1); } Console.WriteLine($"Phase3: 再攒5天后 InterestIncomeSum={runningSum:F6}"); // --- Phase 4: 再互换结清 --- var svc4 = new StubEodPositionService(); var preEodFinal = CreatePreEod(runningDate.AddDays(-1), runningSum); preEodFinal.RealizedInterest = swapResult1.RealizedInterest; var swapEvt2 = CreateSwapFlowEvent(runningDate, runningSum); var swapResult2 = svc4.ExecuteSaveEodInterestPosition( preEodFinal, null, position, td, runningDate, new List { swapEvt2 }); decimal secondRealized = swapResult2.TdCloseInterest; Console.WriteLine($"Phase4: 第二次互换 TdCloseInterest={secondRealized:F6}, 待实现={swapResult2.InterestIncomeSum:F6}"); // 守恒:两次互换实现的 + 最终待实现 = 全程天数 × dailyInc // 全程天数 = 10天(Phase1) + 1天(第一次互换当天新计) + 5天(Phase3) + 1天(第二次互换当天新计) = 17天 // 但第一次互换的当天新计进了 InterestIncomeSum 没进 TdCloseInterest, // 第二次互换同理。所以守恒 = RealizedInterest合计 + 最终InterestIncomeSum = 全程利息 decimal totalDays = 10 + 1 + 5 + 1; // 17天 decimal expectedTotalInterest = Math.Round(Principal * FixedRate * totalDays / AnnualDays, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); decimal actualTotal = swapResult2.RealizedInterest + swapResult2.InterestIncomeSum; Console.WriteLine($"守恒: RealizedInterest({swapResult2.RealizedInterest:F6}) + InterestIncomeSum({swapResult2.InterestIncomeSum:F6}) = {actualTotal:F6}"); Console.WriteLine($"期望: {totalDays}天 × {dailyInc:F6} = {expectedTotalInterest:F6}"); AssertDecimal(expectedTotalInterest, actualTotal, "已实现+待实现 应=全程利息(守恒)"); } #endregion // ================================================================ // 场景5:预付金腿(marginTypes ratio 翻转)符号验证 // ================================================================ #region 场景5:预付金腿 ratio 翻转 /// /// [DI_MARGIN_001] 预付金腿互换结清后 RealizedInterest 应为负(支付方向) /// --------------------------------------------------------------- /// 预付金腿 InterestDirection=收取(1),但 marginTypes 会把 ratio 翻转为 -1。 /// SwapPositionValue 应为负(负债),RealizedInterest 也应为负(券商支付)。 /// 验证 cs:789-793 的 ratio 翻转逻辑。 /// --------------------------------------------------------------- /// [TestMethod] public void DI_MARGIN_001_预付金腿RealizedInterest为负() { var service = new StubEodPositionService(); var td = CreateTrade(); var settleDate = new DateTime(2026, 5, 10); // 预付金腿(初始预付金 InterestMode=5,InterestDirection=收取) var marginPosition = new swap_position { id = 2001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.初始预付金, InterestRateDefault = 0.005m, InterestPrincipalFix = 500m, PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate, IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, interest_rest_days = 1, interest_rule = 0, FloatRateUnderlyingCode = null, InterestSwapInterval = null }; // 攒10天的预付金利息 decimal marginDaily = Math.Round(500m * 0.005m / AnnualDays, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); decimal margin10days = marginDaily * 10; var preEod = new eod_swap_position { id = 200, SwapTradeId = 1, PositionId = 2001, ValueDate = settleDate.AddDays(-1), ClientId = 999998, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.初始预付金, InterestIncomeSum = margin10days, InterestProfitSum = margin10days, RealizedInterest = 0m, InterestRateDefault = 0.005m, TdInterestPrincipal = 500m, PosiNotionalValue = 500m, InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, interest_rest_days = 1, FloatRate = 0m }; // 互换结清 var swapEvent = new swap_flow_event { id = 3001, SwapTradeId = 1, EventType = (int)SwapFlowEventTypeEnum.互换, EventDate = settleDate, UnwindDate = settleDate, PositionId = 2001, InterestDirection = (int)SwapDirectionEnum.收取, InterestAmount = margin10days, InterestClosePnL = -margin10days, // 预付金 ratio 翻转后为负 InterestRate = 0.005m, InterestMode = (int)InterestModeEnum.初始预付金, InterestPrincipal = 500m, FloatRate = 0m, DataState = (int)SwapFlowDateStateEnum.完成 }; var result = service.ExecuteSaveEodInterestPosition( preEod, null, marginPosition, td, settleDate, new List { swapEvent }); // 预付金 marginTypes 翻转 ratio=-1 // RealizedInterest = 0 + TdCloseInterest(margin10days) * ratio(-1) = -margin10days AssertDecimal(-margin10days, result.RealizedInterest, "预付金腿 RealizedInterest 应为负(ratio翻转后支付方向)"); Console.WriteLine($"预付金腿 RealizedInterest={result.RealizedInterest:F6}(负=支付)✅"); Console.WriteLine($"SwapPositionValue={result.SwapPositionValue:F6}(应≈当天新计×ratio=-正)"); } #endregion // ================================================================ // 场景6:自动互换两位实际结算与到期清零 // ================================================================ #region 场景6:自动互换尾差与最终结算 /// /// [DI_AUTO_SETTLEMENT_001] 非最终自动互换:实际结算按两位,尾差继续保留在待实现。 /// 0.0082 四舍五入后实际结算 0.01,待实现应为 0.0082 - 0.01 = -0.0018。 /// [TestMethod] public void DI_AUTO_SETTLEMENT_001_非最终自动互换保留舍入尾差() { var service = new StubEodPositionService { AutoInterests = new List { CreateAutoSwapFlowEvent(new DateTime(2026, 5, 10), 0.0082m) } }; var td = CreateTrade(); var position = CreateInterestPosition(); var result = service.ExecuteSaveAutoEodInterestPosition( CreatePreEod(new DateTime(2026, 5, 9), 0m), position, td, new DateTime(2026, 5, 10), new IntervalModel { Date = new DateTime(2026, 5, 10), Rate = FixedRate, Settlement = 1 }); AssertDecimal(0.01m, result.TdCloseInterest, "日终当日已实现必须使用两位实际结算金额"); AssertDecimal(-0.0018m, result.InterestIncomeSum, "非最终结算的尾差必须继续留在待实现"); AssertDecimal(0.01m, service.AutoInterests.Single().InterestAmount, "自动互换流水金额必须为两位"); AssertDecimal(0.01m, service.AutoInterests.Single().InterestClosePnL, "资金汇总使用的流水损益必须为两位"); } /// /// [DI_AUTO_SETTLEMENT_002] 到期自动互换:仍按两位实际结算,但不存在后续计息时待实现必须清零。 /// [TestMethod] public void DI_AUTO_SETTLEMENT_002_到期自动互换清零待实现() { var service = new StubEodPositionService { AutoInterests = new List { CreateAutoSwapFlowEvent(ExerciseDate, 0.0082m) } }; var td = CreateTrade(); var position = CreateInterestPosition(); var result = service.ExecuteSaveAutoEodInterestPosition( CreatePreEod(ExerciseDate.AddDays(-1), 0m), position, td, ExerciseDate, new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 1 }); AssertDecimal(0.01m, result.TdCloseInterest, "到期自动结算仍按金额两位落库"); AssertDecimal(0m, result.InterestIncomeSum, "到期最终自动结算后不得遗留待实现尾差"); } /// /// [DI_AUTO_SETTLEMENT_003] 自动互换后的后续部分平仓必须续接尾差和累计已实现。 /// 7/7 自动互换将 0.008191780822 按 0.01 实际结算,留下 -0.001808219178; /// 7/8 平仓一半后,待实现继续参与计算,7/9 全平时才清零。 /// [TestMethod] public void DI_AUTO_SETTLEMENT_003_自动互换后部分平仓续接尾差和已实现() { var service = new StubEodPositionService { AutoInterests = new List { CreateAutoSwapFlowEvent(new DateTime(2026, 5, 10), 0.008191780822m) } }; var td = CreateTrade(); var position = CreateInterestPosition(); var autoResult = service.ExecuteSaveAutoEodInterestPosition( CreatePreEod(new DateTime(2026, 5, 9), 0m), position, td, new DateTime(2026, 5, 10), new IntervalModel { Date = new DateTime(2026, 5, 10), Rate = FixedRate, Settlement = 1 }); var firstCloseDate = new DateTime(2026, 5, 11); var firstCloseFlow = CreateSwapFlowEvent(firstCloseDate, 0.01m); firstCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; firstCloseFlow.InterestPrincipal = 50m; // 模拟 CalcUnwindInterest:上日尾差加当日新增,尚未扣除本次 0.01 平仓结算。 service.AutoInterests = new List { CreateAutoSwapFlowEvent(firstCloseDate, 0.016383561644m) }; service.AutoInterests[0].InterestPrincipal = 50m; var firstCloseResult = service.ExecuteSaveAutoEodWithCloseInterestPosition( autoResult, position, td, firstCloseDate, null, 50m, 0m, new List { firstCloseFlow }, 50m, false); AssertDecimal(-0.001808219178m, service.LastInterestCalculationEodPosition.InterestProfitSum, "部分平仓计算必须带入自动互换遗留的待实现尾差"); Assert.AreEqual(position.id, service.LastInterestCalculationEodPosition.PositionId, "部分平仓计息必须按腿标识匹配上一日日终"); AssertDecimal(-0.010438356164m, firstCloseResult.InterestIncomeSum, "部分平仓后待实现应延续历史尾差"); AssertDecimal(0.02m, firstCloseResult.RealizedInterest, "部分平仓后累计已实现应包含此前自动互换和本次平仓"); var finalCloseDate = firstCloseDate.AddDays(1); var finalCloseFlow = CreateSwapFlowEvent(finalCloseDate, 0.01m); finalCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; finalCloseFlow.InterestPrincipal = 50m; service.AutoInterests = new List { CreateAutoSwapFlowEvent(finalCloseDate, 0.010479452055m) }; var finalCloseResult = service.ExecuteSaveAutoEodWithCloseInterestPosition( firstCloseResult, position, td, finalCloseDate, null, 0m, 0m, new List { finalCloseFlow }, 50m, false); AssertDecimal(0m, finalCloseResult.InterestIncomeSum, "全平后待实现应清零"); AssertDecimal(0.03m, finalCloseResult.RealizedInterest, "全平后累计已实现应包含自动互换和两次平仓"); } [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.410474008219m; 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_COMPOUND_RESET_PARTIAL_CLOSE_UsesCalculatedPrincipal() { const decimal remainingNotional = 500m; const decimal compoundPrincipalAfterSevenDays = 500.958904m; var closeDate = StartDate.AddDays(7); var service = new StubEodPositionService { AutoInterests = new List { new swap_flow_event { InterestPrincipal = compoundPrincipalAfterSevenDays, InterestRate = FixedRate, InterestAmount = 0m, TdInterestAmount = 0m } } }; var td = CreateTrade(); var position = CreateInterestPosition(); position.InterestType = (int)InterestTypeEnum.复利; position.interest_rest_days = 7; var closeFlow = CreateSwapFlowEvent(closeDate, 0m); closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; var result = service.ExecuteSaveAutoEodWithCloseInterestPosition( CreatePreEod(closeDate.AddDays(-1), 0m), position, td, closeDate, null, remainingNotional, 0m, new List { closeFlow }, remainingNotional, false); AssertDecimal(compoundPrincipalAfterSevenDays, result.TdInterestPrincipal, "复利重置日部分平仓后,EOD 本金必须保留 CalcSwapInterests 计算的 7 天复利本金"); AssertDecimal(compoundPrincipalAfterSevenDays * FixedRate / AnnualDays, result.TdInterestIncome, "复利重置日部分平仓后,当日利息必须使用已结转待实现利息的剩余复利本金"); } [TestMethod] public void DI_MANUAL_PREPAY_PARTIAL_CLOSE_UsesHistoryPlusRemainingDailyInterest() { var service = new StubEodPositionService(); var td = CreateTrade(); var position = CreateInterestPosition(); position.InterestMode = (int)InterestModeEnum.初始预付金; position.InterestPrincipalFix = Principal; var previousEod = CreatePreEod(StartDate.AddDays(2), 100m); previousEod.InterestMode = position.InterestMode; previousEod.TdInterestPrincipal = Principal; var closeFlow = CreateSwapFlowEvent(StartDate.AddDays(3), 50m); closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓; closeFlow.InterestPrincipal = 500m; var result = service.ExecuteSaveAutoEodWithCloseInterestPosition( previousEod, position, td, StartDate.AddDays(3), null, 500m, 0m, new List { closeFlow }, 500m, false); var expected = previousEod.InterestIncomeSum + result.TdInterestIncome - result.TdCloseInterest; AssertDecimal(expected, result.InterestIncomeSum, "预付金部分平仓待实现收益应为历史待实现+平仓后当日新增-平仓实现"); } [DataTestMethod] [DataRow((int)InterestModeEnum.合约名义本金规模)] [DataRow((int)InterestModeEnum.标的期初全价)] public void DI_MANUAL_PARTIAL_CLOSE_CalcLastIncludesClosedPrincipalDailyInterest(int interestMode) { var service = new StubEodPositionService(); var td = CreateTrade(); td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson { AnnualDays = AnnualDays, InterestCalcMode = "11", SettlementRules = 0 }); var position = CreateInterestPosition(); position.InterestMode = interestMode; 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, 500m, 0m, new List { closeFlow }, 500m, false); AssertDecimal(Principal * FixedRate / AnnualDays, result.TdInterestIncome, "算尾部分平仓的当日新计利息应包含已平仓部分(按全额本金计提)"); AssertDecimal(500m, result.TdInterestPrincipal, "算尾部分平仓后的 EOD 本金应只携带剩余持仓"); // 补充:算尾部分平仓的待实现利息总额应满足递推 // InterestIncomeSum = 前日待实现 + 当日新计(全额本金,含被平仓部分) - 当日实现 // 钉死"算尾 → 被平仓部分多1天利息 → 进入待实现总额"的完整链条 decimal expectedIncomeSum = previousEod.InterestIncomeSum + result.TdInterestIncome - result.TdCloseInterest; AssertDecimal(expectedIncomeSum, result.InterestIncomeSum, "算尾部分平仓: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() { var service = new StubEodPositionService(); var td = CreateTrade(); var position = CreateInterestPosition(); 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, 500m, 0m, new List { closeFlow }, 500m, false); AssertDecimal(500m * FixedRate / AnnualDays, result.TdInterestIncome, "不算尾部分平仓的当日新计利息只应包含剩余持仓部分(按剩余本金计提)"); // 补充对照:不算尾时待实现利息总额同样满足递推,但当日新计只含剩余持仓 // 与 CalcLast 测试对照:不算尾的 TdInterestIncome 更小(差额=被平仓部分1天利息), // 因此 InterestIncomeSum 也相应更小——证明算尾/不算尾的差异确实传导到待实现总额 decimal expectedIncomeSum = previousEod.InterestIncomeSum + result.TdInterestIncome - result.TdCloseInterest; AssertDecimal(expectedIncomeSum, result.InterestIncomeSum, "不算尾部分平仓:InterestIncomeSum 应=前日待实现+当日新计(仅剩余持仓)-当日实现"); } [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, 1m, (int)SwapEventTypeEnum.平仓, false, 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"); } [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, remainingNotional, 0.5m, (int)SwapEventTypeEnum.平仓, false, 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, 1m, (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false).Single(); var replayAtFinalClose = dealService.GetInterests( td, td.trade_extend, finalCloseDate, finalCloseDate, new List(), new List { position }, remainingNotional, remainingNotional, 1m, (int)SwapEventTypeEnum.平仓, false, 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, 1m, (int)SwapEventTypeEnum.平仓, false, 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, partialNotional, partialPercent, (int)SwapEventTypeEnum.平仓, false, 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, 1m, (int)SwapEventTypeEnum.平仓, false, 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 应保留部分平仓后的待实现复利"); AssertDecimal(previousEod.TdInterestPrincipal * (1m - partialNotional / originalNotional), partialEod.TdInterestPrincipal, "5/11 EOD 跨日复利本金应保留剩余70%动态本金"); var intermediateInterest = dealService.GetInterests( td, td.trade_extend, intermediateDate, intermediateDate, new List { partialEod }, new List { position }, remainingNotional, remainingNotional, 1m, (int)SwapEventTypeEnum.平仓, false, 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}"); } [DataTestMethod] [DataRow((int)InterestModeEnum.合约名义本金规模)] [DataRow((int)InterestModeEnum.标的期初全价)] public void DI_GLMS_20260421_0005_EodPartialCloseUsesRemainingCompoundAccrualWhenCalcLastFalse(int interestMode) { 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 finalCloseDate = new DateTime(2026, 5, 19); var td = new trade { id = 1828, TradeNumber = "GLMS-20260421-0005", 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 = 1828, ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson { AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0 }) } }; var position = new swap_position { id = 18281, SwapTradeId = td.id, PositionType = (int)PositionTypeFlag.Unknown, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = interestMode, 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 = 18282, 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.318344137434m, 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 = 80002.30m, InterestClosePnL = 80002.30m, DataState = (int)SwapFlowDateStateEnum.完成 }; var partialEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition( previousEod, position, td, partialCloseDate, null, remainingNotional, 0m, new List { partialCloseFlow }, partialNotional, false); AssertDecimal(9540.163676094769m, partialEod.TdInterestIncome, "0005 计算不算尾时,部分平仓日终新增复利必须按剩余70%本金计提"); AssertDecimal(196212.213529615939m, partialEod.InterestIncomeSum, "0005 部分平仓后日终待实现复利必须扣除实际80002.30结算"); AssertDecimal(previousEod.TdInterestPrincipal * (1m - partialNotional / originalNotional), partialEod.TdInterestPrincipal, "0005 部分平仓后,下一日复利本金必须只继承剩余70%本金"); var intermediateDate = new DateTime(2026, 5, 18); var intermediateInterest = dealService.GetInterests( td, td.trade_extend, intermediateDate, intermediateDate, new List { partialEod }, new List { position }, remainingNotional, remainingNotional, 1m, (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false, newCalcLast: true).Single(); Assert.IsTrue(Math.Abs(259348.386714765m - intermediateInterest.InterestAmount) <= 0.01m, $"0005 5/18 复利应承接部分平仓后的累计利息 Expected approximately 259348.386714765, Actual: {intermediateInterest.InterestAmount}"); var intermediateEod = partialEod.Clone(); intermediateEod.id = 18283; intermediateEod.ValueDate = intermediateDate; intermediateEod.InterestIncomeSum = intermediateInterest.InterestAmount; intermediateEod.InterestProfitSum = intermediateInterest.InterestAmount; intermediateEod.TdInterestPrincipal = remainingNotional; intermediateEod.PosiNotionalValue = remainingNotional; var expectedEndFlow = new swap_flow_event { InterestRate = spread }; decimal expectedAmountAtEnd = 0m; decimal expectedTdAmountAtEnd = 0m; dealService.CalcDailyCompoundInterest( finalCloseDate, position, remainingNotional, expectedEndFlow, AnnualDays, intermediateEod.FloatRate, 1m, true, false, ref expectedAmountAtEnd, ref expectedTdAmountAtEnd); var expectedPreviousFlow = new swap_flow_event { InterestRate = spread }; decimal expectedAmountAtPreviousEod = 0m; decimal expectedTdAmountAtPreviousEod = 0m; dealService.CalcDailyCompoundInterest( intermediateDate, position, remainingNotional, expectedPreviousFlow, AnnualDays, intermediateEod.FloatRate, 1m, true, true, ref expectedAmountAtPreviousEod, ref expectedTdAmountAtPreviousEod); var expectedFinalInterest = intermediateEod.InterestIncomeSum + expectedAmountAtEnd - expectedAmountAtPreviousEod; var finalInterest = dealService.GetInterests( td, td.trade_extend, finalCloseDate, finalCloseDate, new List { intermediateEod }, new List { position }, remainingNotional, remainingNotional, 1m, (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false, newCalcLast: false).Single(); AssertDecimal(expectedFinalInterest, finalInterest.InterestAmount, "0005 最终全平重放时,历史5/18终点必须包含当日利息后再做差额"); } [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, 1m, (int)SwapEventTypeEnum.平仓, false, 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] 到期日存在手动互换但未带齐待实现时不能清零; /// 当前事件按两位覆盖全部可结金额后,才可视为最终结算并清零。 /// [DataTestMethod] [DynamicData(nameof(ExcelScenario4Cases), DynamicDataSourceType.Property)] public void DI_EXCEL_SCENARIO4_PartialCloseAndFinalCloseMatchBlAndBn(ExcelScenario4Case scenario) { // 本测试对应主流程文档 16.13 节。四个规模字段按以下恒等式变化: // 原始本金 303139117.80 = 本次平仓 90941735.34 + 收盘后剩余 212197382.46。 const decimal originalNotional = 303139117.80m; const decimal partialClosePercent = 0.30m; const decimal partialNotional = 90941735.34m; const decimal remainingNotional = 212197382.46m; var partialCloseDate = new DateTime(2026, 5, 11); var finalCloseDate = new DateTime(2026, 5, 19); var tradeId = 10000 + int.Parse(scenario.TradeNumber[^4..]); var td = new trade { id = tradeId, TradeNumber = scenario.TradeNumber, ClientId = 999998, TradeType = "收益互换", TradeDate = new DateTime(2026, 4, 21), StartDate = scenario.StartDate, ExerciseDate = finalCloseDate, TradeStatus = "确认成交", ValidState = "Valid", StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY", trade_extend = new trade_extend { TradeId = tradeId, ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson { AnnualDays = AnnualDays, InterestCalcMode = scenario.InterestCalcMode, SettlementRules = scenario.SettlementRules }) } }; var position = new swap_position { id = tradeId * 10L + 1, SwapTradeId = tradeId, PositionType = (int)PositionTypeFlag.Unknown, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = scenario.InterestMode, InterestRateDefault = scenario.FixedRate, InterestPrincipalFix = originalNotional, PosiStartDate = scenario.StartDate, PosiMatuirityDate = finalCloseDate, IsInitial = true, Invalid = false, InterestType = scenario.InterestType, IsAnnualized = true, interest_rest_days = 7, interest_rule = scenario.InterestRule, FloatRateUnderlyingCode = "FR007", InterestSwapInterval = JsonConvert.SerializeObject(new List { new IntervalModel { Date = finalCloseDate, Rate = scenario.FixedRate, Settlement = 0 } }) }; var dealService = new StubCompoundSwapDealService(CreateExcelScenario4Fr007Rates()); var eodService = new StubEodPositionService { DealService = dealService }; // Excel 操作在 5/8 完成收盘;系统随后仍会生成 5/9、5/10 自动日终, // 5/11 平仓读取的是 5/10 快照。漏掉周末快照会让平仓后待实现少两天全额利息。 var preCloseEodDates = Enumerable.Range(0, (partialCloseDate.AddDays(-1) - scenario.StartDate).Days + 1) .Select(day => scenario.StartDate.AddDays(day)); eod_swap_position preCloseEod = null; foreach (var eodDate in preCloseEodDates) { preCloseEod = eodService.ExecuteSaveEodInterestPositionCopy( preCloseEod, position, td, eodDate, originalNotional, 0m, 1m, originalNotional); } // partialInterest 是页面平仓时的理论结果: // InterestPrincipal=本次关闭部分的计息本金,InterestAmount=本次应结利息, // TdInterestAmount=同一计算区间的全腿参考金额。BL 只核对实际要结的 InterestAmount。 // 例如 0004:InterestPrincipal=90915227.13,InterestAmount=-37119.14。 var partialInterest = dealService.GetInterests( td, td.trade_extend, partialCloseDate, partialCloseDate, new List { preCloseEod }, new List { position }, originalNotional, partialNotional, partialClosePercent, (int)SwapEventTypeEnum.平仓, false, originalNotional, settment: false).Single(); AssertExcelMoney(scenario.ExpectedPartialInterest, partialInterest.InterestAmount, $"{scenario.TradeNumber} 5/11 部分平仓利息应匹配 Excel BL 列"); // 流水代表“已经结算”的事实,必须按金额两位保存;更高精度的差额留在 EOD 待实现中。 var partialCashInterest = Math.Round(partialInterest.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); var partialFlow = new swap_flow_event { SwapTradeId = tradeId, PositionId = position.id, EventType = (int)SwapFlowEventTypeEnum.平仓, EventDate = partialCloseDate, UnwindDate = partialCloseDate, InterestDirection = position.InterestDirection, InterestMode = position.InterestMode, InterestRate = partialInterest.InterestRate, FloatRate = partialInterest.FloatRate, InterestPrincipal = partialInterest.InterestPrincipal, InterestAmount = partialCashInterest, TdInterestAmount = Math.Round(partialInterest.TdInterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero), InterestClosePnL = partialCashInterest, DataState = (int)SwapFlowDateStateEnum.完成 }; // partialEod 是 5/11 收盘后的状态,不是流水副本。以 0004 为例: // TdCloseInterest=-37119.14,InterestIncomeSum=-86611.313284, // RealizedInterest=-37119.14,TdInterestPrincipal=212135529.974418。 var partialEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition( preCloseEod, position, td, partialCloseDate, null, remainingNotional, 0m, new List { partialFlow }, partialNotional, false); // 5/11 部分平仓收盘后继续逐自然日归档到 5/18,保留剩余 70% 仓位的完整利息。 var postCloseEodDates = Enumerable.Range(1, 7) .Select(day => partialCloseDate.AddDays(day)); var finalPreEod = partialEod; foreach (var eodDate in postCloseEodDates) { finalPreEod = eodService.ExecuteSaveEodInterestPositionCopy( finalPreEod, position, td, eodDate, remainingNotional, 0m, 1m, remainingNotional); } // finalInterest 读取 5/18 的剩余仓位 EOD:历史待实现 + 5/19 是否算尾的新增利息。 // 最终 closePercent=100%,因此 InterestAmount 必须带走此前部分平仓留下的全部尾差。 var finalInterest = dealService.GetInterests( td, td.trade_extend, finalCloseDate, finalCloseDate, new List { finalPreEod }, new List { position }, remainingNotional, remainingNotional, 1m, (int)SwapEventTypeEnum.平仓, false, remainingNotional, settment: false).Single(); AssertExcelMoney(scenario.ExpectedFinalInterest, finalInterest.InterestAmount, $"{scenario.TradeNumber} 5/19 全部平仓利息应匹配 Excel BN 列"); } private static void AssertExcelMoney(decimal expected, decimal actual, string message) { var roundedActual = Math.Round(actual, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); Assert.IsTrue(Math.Abs(expected - roundedActual) <= 0.01m, $"{message}。Expected={expected}, Actual={actual}, Rounded={roundedActual}, Diff={expected - roundedActual}"); } [TestMethod] public void DI_MATURITY_SETTLEMENT_001_到期手动互换仅在结清后清零() { var settleDate = ExerciseDate; var td = CreateTrade(); var position = CreateInterestPosition(); position.InterestPrincipalFix = 0m; // 事件未结算此前的 0.0082:到期日也必须保留待实现。 var incompleteService = new StubEodPositionService(); var incompleteEvent = CreateSwapFlowEvent(settleDate, 0m); incompleteEvent.InterestPrincipal = 0m; incompleteEvent.InterestRate = 0m; var incompleteResult = incompleteService.ExecuteSaveEodInterestPosition( CreatePreEod(settleDate.AddDays(-1), 0.0082m), null, position, td, settleDate, new List { incompleteEvent }); AssertDecimal(0.0082m, incompleteResult.InterestIncomeSum, "到期但未结清时不得丢弃历史待实现"); // 前端按两位提交 0.01,可覆盖 0.0082 的最终金额,允许清零。 var finalService = new StubEodPositionService(); var finalEvent = CreateSwapFlowEvent(settleDate, 0.01m); finalEvent.InterestPrincipal = 0m; finalEvent.InterestRate = 0m; var finalResult = finalService.ExecuteSaveEodInterestPosition( CreatePreEod(settleDate.AddDays(-1), 0.0082m), null, position, td, settleDate, new List { finalEvent }); AssertDecimal(0m, finalResult.InterestIncomeSum, "两位最终结算覆盖待实现后应清零"); } #endregion #endregion } }