using Newtonsoft.Json; using YLErp.DBModels; using YLErp.DBModels.Enums; namespace YLErp.Modules.SwapModule { /// /// 复利 consumedInterest 扣除 - 合成单元测试 /// ============================================================================ /// 验证 c6adb3bb 的修复:复利路径平仓时,扣除历史已通过互换结出的利息。 /// /// 核心场景: /// 一笔复利交易,N天后做了互换结算(已结N天利息),之后再平仓。 /// 平仓默认值应 = 从头算的全程利息 - 已结利息(consumedInterest)。 /// 如果不扣(bug),平仓默认值 = 全程利息(偏大)。 /// 如果多扣(之前单利的错误),平仓默认值 = 0或负(偏小)。 /// /// 模仿 GetInterestsUnitTest_T0 的 StubSwapDealService 模式。 /// ============================================================================ [TestClass] public class ConsumedInterestScenarioTest { #region 常量 private const decimal Principal = 1000m; private const decimal FixedRate = 0.0025m; // 加点利率 private const double FloatRate = 0.001; // FR007 private const decimal TotalRate = FixedRate + (decimal)FloatRate; // 综合年化利率 private const int AnnualDays = 365; private const int ResetPeriod = 3; private static readonly DateTime StartDate = new(2026, 4, 27); private static readonly DateTime ExerciseDate = new(2027, 4, 27); #endregion #region Stub:内存 SwapDealService + consumedInterest 注入 /// /// 继承 SwapDealService,override 两个虚方法: /// - TryGetFloatRate:返回固定浮动利率(不连库) /// - GetConsumedInterest:返回注入的历史已结利息(不连库) /// private sealed class StubSwapDealService : SwapDealService { private readonly double _floatRate; private readonly decimal _consumedInterest; private readonly Func _floatRateByDate; public StubSwapDealService(OptUserInfo optUser, double floatRate, decimal consumedInterest) : base(optUser) { _floatRate = floatRate; _consumedInterest = consumedInterest; _floatRateByDate = null; } /// 按查询日期返回不同浮动利率(用于复现重置日取价 bug) public StubSwapDealService(OptUserInfo optUser, Func floatRateByDate, decimal consumedInterest = 0m) : base(optUser) { _floatRate = 0; _consumedInterest = consumedInterest; _floatRateByDate = floatRateByDate; } protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) { rate = _floatRateByDate != null ? _floatRateByDate(valueDate) : _floatRate; return true; } public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) { return _consumedInterest; // 返回注入值 } } #endregion #region 数据构建 private static trade CreateTrade() { return new trade { id = 1, TradeNumber = "UT-CONSUMED-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 CreateCompoundPosition() { 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 = ResetPeriod, interest_rule = 0, FloatRateUnderlyingCode = "FR007", InterestSwapInterval = JsonConvert.SerializeObject(new List { new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 } }) }; } /// 调用 GetInterests 获取复利利息(统一调用入口,settment:false走盘中平仓路径) private static swap_flow_event CalcCompoundUnwind(StubSwapDealService service, DateTime unwindDate, decimal closePercent = 1m) { var td = CreateTrade(); var position = CreateCompoundPosition(); var interests = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate, new List(), new List { position }, Principal, Principal, closePercent, (int)SwapEventTypeEnum.平仓, false, Principal, add: false, settment: false, newCalcLast: false); Assert.AreEqual(1, interests.Count); return interests[0]; } private static StubSwapDealService CreateService(decimal consumedInterest) { return new StubSwapDealService( new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest), FloatRate, consumedInterest); } 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}"); } #endregion // ================================================================ // 场景1:基线——无历史互换(consumedInterest=0),拿到全程复利利息 // ================================================================ /// /// [CI_001] 无历史互换结清,复利平仓利息基线 /// --------------------------------------------------------------- /// consumedInterest=0,平仓利息=从头算的全程复利利息。 /// 此值作为后续场景的参照基线(避免独立复利计算的精度匹配问题)。 /// --------------------------------------------------------------- /// [TestMethod] public void CI_001_无历史互换平仓利息基线() { var unwindDate = StartDate.AddDays(10); // 4/27+10=5/7,算头不算尾约9天 var service = CreateService(consumedInterest: 0m); var result = CalcCompoundUnwind(service, unwindDate); Assert.IsTrue(result.InterestAmount > 0, "无互换时复利利息应>0"); Console.WriteLine($"基线(consumedInterest=0): InterestAmount={result.InterestAmount:F6}"); } // ================================================================ // 场景2:consumedInterest>0 → 平仓利息=基线-consumedInterest // ================================================================ /// /// [CI_002] 注入consumedInterest后,平仓利息应=基线-consumedInterest /// --------------------------------------------------------------- /// 用相同参数但注入不同的consumedInterest,验证: /// 利息(有consumed) = 利息(无consumed) - consumedInterest /// 这是验证cs:793 `interest -= consumedInterest` 的直接方式。 /// --------------------------------------------------------------- /// [TestMethod] public void CI_002_consumedInterest正确扣除() { var unwindDate = StartDate.AddDays(10); // 基线:consumedInterest=0 var baselineResult = CalcCompoundUnwind(CreateService(0m), unwindDate); decimal baseline = baselineResult.InterestAmount; // 注入consumedInterest=基线的50% decimal consumed = baseline * 0.5m; var consumedResult = CalcCompoundUnwind(CreateService(consumed), unwindDate); // 期望 = 基线 - consumed decimal expected = baseline - consumed; AssertDecimal(expected, consumedResult.InterestAmount, $"平仓利息应=基线({baseline:F6})-consumed({consumed:F6})={expected:F6}"); Console.WriteLine($"基线={baseline:F6}, consumed={consumed:F6}"); Console.WriteLine($"平仓利息={consumedResult.InterestAmount:F6}, 期望={expected:F6} ✅"); } // ================================================================ // 场景3:守恒——consumed + 平仓利息 = 基线 // ================================================================ /// /// [CI_003] 守恒:consumedInterest + 平仓利息(扣后) = 基线(无consumed) /// --------------------------------------------------------------- /// 注入任意consumedInterest,验证 consumed + 利息 = 基线。 /// 如果扣多了(守恒不成立→合计<基线)或没扣(合计>基线),测试失败。 /// --------------------------------------------------------------- /// [TestMethod] public void CI_003_守恒consumed加平仓等于基线() { var unwindDate = StartDate.AddDays(10); decimal baseline = CalcCompoundUnwind(CreateService(0m), unwindDate).InterestAmount; // 注入不同的consumedInterest验证守恒 decimal[] testConsumed = { baseline * 0.3m, baseline * 0.5m, baseline * 0.8m }; foreach (var consumed in testConsumed) { var result = CalcCompoundUnwind(CreateService(consumed), unwindDate); decimal actual = consumed + result.InterestAmount; AssertDecimal(baseline, actual, $"守恒: consumed({consumed:F6}) + 利息({result.InterestAmount:F6}) = {actual:F6} 应=基线({baseline:F6})"); Console.WriteLine($"consumed={consumed:F6} + 利息={result.InterestAmount:F6} = {actual:F6} = 基线{baseline:F6} ✅"); } } // ================================================================ // 场景4:consumedInterest=全部基线 → 平仓利息≈0,不为负 // ================================================================ /// /// [CI_004] 全部利息已结清(consumedInterest=基线),再平仓利息应≈0 /// --------------------------------------------------------------- /// 验证不会扣过头变成负数(之前单利双重扣减的错误)。 /// 复利从头算全程 - 全程consumed = 0,应精确归零或微小正值。 /// --------------------------------------------------------------- /// [TestMethod] public void CI_004_全部已结再平仓利息不为负() { var unwindDate = StartDate.AddDays(10); decimal baseline = CalcCompoundUnwind(CreateService(0m), unwindDate).InterestAmount; // consumedInterest=全部基线 var result = CalcCompoundUnwind(CreateService(baseline), unwindDate); Console.WriteLine($"基线={baseline:F6}, consumed={baseline:F6}, 平仓利息={result.InterestAmount:F6}"); Assert.IsTrue(result.InterestAmount >= -0.01m, $"全部已结再平仓利息应≈0(实际={result.InterestAmount:F6}),不应为负"); Console.WriteLine($"全部已结平仓≈0({result.InterestAmount:F6})✅"); } [TestMethod] public void CI_005_partialClose_scalesConsumedInterest() { var unwindDate = StartDate.AddDays(10); const decimal closePercent = 0.4m; const decimal consumed = 100m; var baseline = CalcCompoundUnwind(CreateService(0m), unwindDate, closePercent).InterestAmount; var result = CalcCompoundUnwind(CreateService(consumed), unwindDate, closePercent).InterestAmount; AssertDecimal(baseline - consumed * closePercent, result, $"partial close should deduct consumed interest by closePercent ({closePercent})"); } // ================================================================ // 场景7:复现"平仓日=重置日 + calcLast=false → 重置日跳过 FR007 取价" // ================================================================ /// /// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过 /// ---------------------------------------------------------------- /// 背景(GLMS-JIATT-20260805 根因):InterestCalcMode='10'(算头不算尾,calcLast=false), /// CalcDailyCompoundInterest 循环里 `if(!calcLast && accrueDate==endDate) continue` 会跳过平仓日当天。 /// 若平仓日恰好是重置日(i%period==0),这个跳过会让"重置日取新FR007"的代码块永远不执行, /// 沿用上一个重置周期的旧利率。 /// /// 构造:PosiStartDate=4/27, ResetPeriod=3, InterestCalcMode='10'(calcLast=false) /// - FR007 按日期分段:5/3之前返回 rateOld=0.001,5/3及之后返回 rateNew=0.002 /// - 对照A:平仓日=5/5(非重置日,9? 不: (5/5-4/27)=8, 8%3=2 非重置) → 不该取新值 /// - 对照B:平仓日=5/6(重置日,(5/6-4/27)=9, 9%3=0) → 应取新值 rateNew /// /// 修复前:5/6 重置日被 calcLast 跳过 → 取到旧 rateOld → 与 5/5 相同 /// 修复后:5/6 重置日正常取价 → 取到 rateNew → 与 5/5 不同 /// ---------------------------------------------------------------- /// /// /// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过 /// ---------------------------------------------------------------- /// 根因(GLMS-JIATT-20260805):InterestCalcMode='10'(calcLast=false), /// CalcDailyCompoundInterest 循环 `if(!calcLast && accrueDate==endDate) continue` 跳过平仓日。 /// 若平仓日=重置日,取价代码块被跳过 → flowEvent.FloatRate 停留旧值 → 落库后传染 EOD。 /// /// 构造(避开周末,period=7): /// PosiStartDate=4/27(周一), period=7, interest_rule=0, InterestCalcMode='10' /// 重置日:i=0→4/27(周一), i=7→5/4(周一,工作日) /// 平仓日=5/4(=重置日=endDate) /// FR007 分界:rateDate>=5/4 返回 rateNew,否则 rateOld /// /// 修复前:i=7(5/4)被 calcLast 跳过 → FloatRate=rateOld(旧值) /// 修复后:i=7(5/4)正常取价 → FloatRate=rateNew(新值) /// ---------------------------------------------------------------- /// [TestMethod] public void CI_007_平仓日等于重置日_calcLast_false_仍应取新FR007() { const double rateOld = 0.001; const double rateNew = 0.002; // 用 6 月日期避开五一/周末:PosiStartDate=6/1(周一), period=7, 平仓日=6/8(周一,重置日) DateTime posiStart = new DateTime(2026, 6, 1); DateTime unwindDate = new DateTime(2026, 6, 8); // (6/8-6/1)=7, 7%7=0 重置日 DateTime newRateFrom = new DateTime(2026, 6, 8); // 6/8(查询日,周一工作日)起为新利率 StubSwapDealService ServiceByDate() => new StubSwapDealService( new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest), d => d >= newRateFrom ? rateNew : rateOld); var td = new trade { id = 1, TradeNumber = "UT-CI007", ClientId = 999998, TradeType = "收益互换", TradeDate = posiStart, StartDate = posiStart, 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 }) } }; var position = new swap_position { id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = FixedRate, InterestPrincipalFix = Principal, PosiStartDate = posiStart, PosiMatuirityDate = ExerciseDate, 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 = ExerciseDate, Rate = FixedRate, Settlement = 0 } }) }; var interests = ServiceByDate().GetInterests(td, td.trade_extend, unwindDate, unwindDate, new List(), new List { position }, Principal, Principal, 1m, (int)SwapEventTypeEnum.平仓, false, Principal, add: false, settment: false, newCalcLast: false); Assert.AreEqual(1, interests.Count); var result = interests[0]; Console.WriteLine($"6/8(重置日,周一)平仓:FloatRate={result.FloatRate} Amount={result.InterestAmount:F6}"); Console.WriteLine($" 期望 FloatRate={rateNew}(6/8 重置日查询日=6/8工作日,应取新利率)"); // 核心断言:6/8 是重置日,flowEvent.FloatRate 应反映新利率 rateNew Assert.IsTrue(Math.Abs((result.FloatRate ?? 0) - (decimal)rateNew) < 0.0001m, $"平仓日=重置日时 FloatRate 应={rateNew}(取到新利率)。" + $"实际={result.FloatRate},若={rateOld} 说明 calcLast=false 跳过了重置日取价(GLMS-JIATT-20260805 根因)"); var interestBeforeResetDate = Principal * (FixedRate + (decimal)rateOld) * 7m / AnnualDays; AssertDecimal(Principal + interestBeforeResetDate, result.InterestPrincipal, "calcLast=false 的重置日仍应将前 7 天复利并入本金"); AssertDecimal(interestBeforeResetDate, result.InterestAmount, "calcLast=false 不应计入重置日当天利息"); } [TestMethod] public void CI_008_ResetDayPartialCloseCarriesRemainingInterestIntoPrincipal() { const decimal previousPrincipal = 50061728.39m; const decimal remainingPrincipal = 30037037.04m; const decimal previousInterest = 7425.050203320057m; const decimal fixedRate = 0.001234m; const double oldFloatRate = 0.0123; const double newFloatRate = 0.0213; var startDate = new DateTime(2026, 7, 28); var resetDate = new DateTime(2026, 8, 4); var service = new StubSwapDealService( new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest), d => d >= resetDate ? newFloatRate : oldFloatRate); var td = CreateTrade(); td.StartDate = startDate; td.TradeDate = startDate; var position = new swap_position { id = 1001, SwapTradeId = td.id, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价, InterestType = (int)InterestTypeEnum.复利, InterestRateDefault = fixedRate, PosiStartDate = startDate, PosiMatuirityDate = ExerciseDate, IsAnnualized = true, interest_rest_days = 7, interest_rule = 0, FloatRateUnderlyingCode = "FR007", InterestSwapInterval = JsonConvert.SerializeObject(new List { new IntervalModel { Date = ExerciseDate, Rate = fixedRate, Settlement = 0 } }) }; var preEod = new eod_swap_position { id = 1, PositionId = position.id, ValueDate = resetDate.AddDays(-1), TdInterestPrincipal = previousPrincipal, InterestIncomeSum = previousInterest, InterestProfitSum = previousInterest, FloatRate = (decimal)oldFloatRate }; var result = service.GetInterests(td, td.trade_extend, resetDate, resetDate, new List { preEod }, new List { position }, remainingPrincipal, remainingPrincipal, 1m, (int)SwapEventTypeEnum.平仓, true, remainingPrincipal, add: false, settment: false, newCalcLast: false).Single(); var remainingInterest = previousInterest * remainingPrincipal / previousPrincipal; var expectedPrincipal = remainingPrincipal + remainingInterest; var expectedDailyInterest = expectedPrincipal * (fixedRate + (decimal)newFloatRate) / AnnualDays; AssertDecimal(expectedPrincipal, result.InterestPrincipal); AssertDecimal(expectedDailyInterest, result.InterestPrincipal * (result.InterestRate + result.FloatRate.Value) / AnnualDays); } [TestMethod] public void CI_009_NonResetUnwindWithCalcLastFalseUsesPreviousEodPendingInterest() { const decimal pendingInterest = 10019.043756537721m; const decimal remainingPrincipal = 30041492.070122881942m; var startDate = new DateTime(2026, 7, 28); var unwindDate = new DateTime(2026, 8, 7); var td = CreateTrade(); td.StartDate = startDate; td.TradeDate = startDate; var position = new swap_position { id = 1001, SwapTradeId = td.id, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价, InterestType = (int)InterestTypeEnum.复利, InterestRateDefault = 0.001234m, PosiStartDate = startDate, PosiMatuirityDate = ExerciseDate, IsAnnualized = true, interest_rest_days = 7, FloatRateUnderlyingCode = "FR007" }; var preEod = new eod_swap_position { id = 1, PositionId = position.id, ValueDate = unwindDate.AddDays(-1), TdInterestPrincipal = remainingPrincipal, InterestIncomeSum = pendingInterest, InterestProfitSum = pendingInterest, FloatRate = 0.0213m }; var service = new StubSwapDealService( new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest), 0.0213, 0m); var result = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate, new List { preEod }, new List { position }, remainingPrincipal, remainingPrincipal, 1m, (int)SwapEventTypeEnum.平仓, false, remainingPrincipal, add: false, settment: false, newCalcLast: false).Single(); AssertDecimal(pendingInterest, result.InterestAmount, "calcLast=false must not accrue unwind-date interest after the previous EOD"); } [TestMethod] public void CI_010_EodResetWithoutCloseCarriesFullPendingInterest() { const decimal principal = 303139117.80m; const decimal previousBase = 303230391.742592383565m; const decimal pendingInterest = 184331.611361300669m; var startDate = new DateTime(2026, 4, 21); var resetDate = new DateTime(2026, 4, 28); var position = new swap_position { PosiStartDate = startDate, InterestType = (int)InterestTypeEnum.复利, InterestRateDefault = 0.0025m, InterestSwapInterval = JsonConvert.SerializeObject(new List()), IsAnnualized = true, interest_rest_days = 7, FloatRateUnderlyingCode = "FR007" }; var preEod = new eod_swap_position { id = 1, ValueDate = resetDate.AddDays(-1), TdInterestPrincipal = previousBase, InterestIncomeSum = pendingInterest, InterestProfitSum = pendingInterest, FloatRate = 0.013502m }; var flowEvent = new swap_flow_event { InterestRate = 0.0025m }; var service = new StubSwapDealService( new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest), d => 0.0139); decimal interestAmount = 0m; decimal tdInterestAmount = 0m; service.CalcDailyCompoundInterestByEod(preEod, resetDate, startDate, position, principal, principal, flowEvent, AnnualDays, 0.013502m, 1m, ref interestAmount, ref tdInterestAmount); AssertDecimal(principal + pendingInterest, flowEvent.InterestPrincipal, "无平仓重置日必须完整并入上一期累计待实现利息"); } } }