using System.Text.RegularExpressions; using YLErp.Core.Interest; using YLErp.Derivatives.Interest; namespace UnitTestProject.Modules.SwapModule.Accrual { /// /// 聚焦测试:AccrueCompoundInArrears 的「本金滚存时机」必须符合确认书规定。 /// 核心不变量:本金只允许在重置日/段末滚入利息,非重置日不得资本化。 /// /// 与原草稿的关键区别:本版直接通过 AccrualTrace 断言不变量。 /// 真实实现在每次段末会发出 ROLLOVER 事件并记录 newBasis(见 SwapInterest.cs:215 / /// AccrualTrace.Rollover),因此「非重置日是否发生资本化」是可程序化验证的, /// 无需仅靠总利息回归来保护(原草稿的自我怀疑"无法断言计息基数"已不成立)。 /// [TestClass] public class SwapInterest_CompoundInArrears_RolloverTimingTests { private const int FundingLegPrecision = 12; private const int AnnualDays = 365; /// /// 场景:14天窗口,第8天(01-08)重置一次,利率恒定 3.65%(日利率 0.01%)。 /// 验证: /// (1) 总利息 = 1400.49(第1期700 + 第2期700.49); /// (2) ROLLOVER 仅发生在重置日(01-08)与窗口终点(01-15),非重置日(如01-03)绝不滚存; /// (3) 重置日 ROLLOVER 的 newBasis = 原始本金 + 前7天利息 = 1,000,700, /// 证明第1段计息基数恒为原始本金、段内未提前资本化。 /// [TestMethod] public void InterestPrincipal_ShouldRollOnlyOnResetDays_NotOnNonResetDays() { var startDate = new DateTime(2026, 1, 1); var endDate = new DateTime(2026, 1, 15); var principal = 1_000_000m; var rate = 0.0365m; var resetDates = new List { new DateTime(2026, 1, 8) }; var trace = new AccrualTrace(); var ctx = new AccrualContext(AnnualDays, FundingLegPrecision, trace); var result = SwapInterest.AccrueCompoundInArrears( ctx, principal, rate, startDate, endDate, AccrualBoundary.Both, resetDates); Assert.AreEqual(1400.49m, Math.Round(result.Accrued, 2)); var rolloverDates = trace.Entries .Where(e => e.Step == AccrualTraceEvent.Rollover) .Select(e => e.Date) .ToList(); var allowed = resetDates.Concat(new[] { endDate }).OrderBy(d => d).ToList(); CollectionAssert.AreEqual(allowed, rolloverDates.OrderBy(d => d).ToList()); Assert.IsFalse(rolloverDates.Contains(new DateTime(2026, 1, 3)), "非重置日发生了本金滚存,违反确认书规定"); var resetRollover = trace.Entries .First(e => e.Step == AccrualTraceEvent.Rollover && e.Date == new DateTime(2026, 1, 8)); var newBasis = ParseNewBasis(resetRollover.Line); Assert.AreEqual(principal + 700m, newBasis, "重置日滚入的本金应为原始本金 + 前段利息,证明段内未提前资本化"); } /// /// 极端场景:startDate = endDate(1天),无重置日。 /// 期望利息 = 本金 × 日利率 = 1,000,000 × 0.0365/365 = 100。 /// 且唯一 ROLLOVER 必须落在窗口终点(=startDate),无任何内部重置滚存。 /// [TestMethod] public void SingleDay_ShouldNotRollInterest_NoResetDay() { var date = new DateTime(2026, 1, 1); var principal = 1_000_000m; var rate = 0.0365m; var trace = new AccrualTrace(); var ctx = new AccrualContext(AnnualDays, FundingLegPrecision, trace); var result = SwapInterest.AccrueCompoundInArrears( ctx, principal, rate, date, date, AccrualBoundary.Both); Assert.AreEqual(100m, Math.Round(result.Accrued, 2)); var rolloverDates = trace.Entries .Where(e => e.Step == AccrualTraceEvent.Rollover) .Select(e => e.Date) .ToList(); CollectionAssert.AreEqual(new[] { date }, rolloverDates.ToArray()); } /// /// 段内无重置日:验证整段等同于单利,且不发生任何内部滚存。 /// 6天窗口(01-01..01-06)在7天重置周期内,Both 边界含两端 = 6 个计息日, /// 期望利息 = 本金 × 日利率 × 6 = 600。 /// [TestMethod] public void WithinPeriod_NoRollover_ShouldMatchSimpleInterest() { var startDate = new DateTime(2026, 1, 1); var endDate = new DateTime(2026, 1, 6); var principal = 1_000_000m; var rate = 0.0365m; var trace = new AccrualTrace(); var ctx = new AccrualContext(AnnualDays, FundingLegPrecision, trace); var result = SwapInterest.AccrueCompoundInArrears( ctx, principal, rate, startDate, endDate, AccrualBoundary.Both); // 计息天数必须用边界感知的 AccrualDays,不能拿 (end-start).Days(会少算1天) var days = SwapInterest.AccrualDays(startDate, endDate, AccrualBoundary.Both); // = 6 var expected = Math.Round(principal * rate * days / AnnualDays, FundingLegPrecision, MidpointRounding.AwayFromZero); Assert.AreEqual(expected, Math.Round(result.Accrued, 10)); var rolloverDates = trace.Entries .Where(e => e.Step == AccrualTraceEvent.Rollover) .Select(e => e.Date) .ToList(); CollectionAssert.AreEqual(new[] { endDate }, rolloverDates.ToArray()); } private static decimal ParseNewBasis(string line) { var m = Regex.Match(line, @"newBasis=([0-9.]+)"); Assert.IsTrue(m.Success, $"ROLLOVER 行缺少 newBasis:{line}"); return decimal.Parse(m.Groups[1].Value); } } }