diff --git a/UnitTestProject/Modules/SwapModule/Accrual/SwapInterest_CompoundInArrears_RolloverTimingTests.cs b/UnitTestProject/Modules/SwapModule/Accrual/SwapInterest_CompoundInArrears_RolloverTimingTests.cs new file mode 100644 index 00000000..816be349 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Accrual/SwapInterest_CompoundInArrears_RolloverTimingTests.cs @@ -0,0 +1,144 @@ +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); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs b/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs index d291a37b..b2f4dab1 100644 --- a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs +++ b/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs @@ -7,12 +7,12 @@ namespace YLErp.Modules.SwapModule.Accrual; /// 融资腿计息编排层——纯数学部分(替换 SwapDealService 内 CalcDaily* 家族的纯计算)。 /// /// 命名规范(对齐 QuantLib / Strata): -/// - notional/principal → 计息基数 +/// - notional → 计息名义本金(不用 principal,swap leg 用 notional 是业界标准) /// - accrued → 累计应计利息 /// - unwindFraction → 平仓比例(0~1) /// - realizedInterest → 历史已结利息(legacy: consumedInterest) -/// - referenceNotional → 差分公式参考本金(legacy: orginPv/originalPv) -/// - accrualBasis → 逐段滚动的计息基数(legacy: dynomicPrincipal) +/// - baseNotional → 差分公式基数(legacy: orginPv/originalPv) +/// - accrualBasis → 逐段滚动的计息基数(legacy: dynomicPrincipal=dynamicPrincipal typo) /// public static class FundingLegAccrual { @@ -21,23 +21,18 @@ public static class FundingLegAccrual /// /// 单利日终计息(替换 CalcDailySimpleInterestByEod 的纯数学部分)。 /// - /// 上一日终累计应计利息。 - /// 上一日终计息基数。 - /// 当前持仓名义本金。 - /// 平仓比例(EOD 恒为 1)。 - /// 差分公式参考本金(legacy: orginPv)。 public static InterestResult AccrueSimpleEod( decimal priorAccrued, decimal priorNotional, decimal notional, decimal unwindFraction, - decimal referenceNotional, + decimal baseNotional, FundingLegRate rate, AccrualPolicy policy, DateTime eodDate, AccrualTrace? trace = null) { - var basis = priorNotional + notional - referenceNotional; + var basis = priorNotional + notional - baseNotional; var displayBasis = basis * unwindFraction; var allInRate = rate.AllInRate; @@ -62,14 +57,14 @@ public static class FundingLegAccrual /// /// 复利日终计息(替换 CalcDailyCompoundInterestByEod 的纯数学部分)。 /// 重置日:basis = notional + priorAccrued × remainingFraction。 - /// 非重置日:basis = priorNotional + notional - referenceNotional。 + /// 非重置日:basis = priorNotional + notional - baseNotional。 /// public static InterestResult AccrueCompoundEod( decimal priorAccrued, decimal priorNotional, decimal notional, decimal unwindFraction, - decimal referenceNotional, + decimal baseNotional, FundingLegRate rate, AccrualPolicy policy, bool isResetDay, @@ -79,7 +74,7 @@ public static class FundingLegAccrual { var basis = isResetDay ? notional + priorAccrued * remainingFraction - : priorNotional + notional - referenceNotional; + : priorNotional + notional - baseNotional; var displayBasis = basis * unwindFraction; var allInRate = rate.AllInRate; @@ -105,11 +100,6 @@ public static class FundingLegAccrual /// 单利多日计息(替换 CalcDailySimpleInterest 的纯数学部分)。 /// 本金全程恒定,按重置日分段取利率。 /// - /// 上一日终累计应计利息。 - /// 计息基数(差分,全程恒定)。 - /// 平仓比例。 - /// 分段利率表:(段起日, all-in利率),按日期升序。 - /// 上一日终归档日(只算此日之后的利息)。 public static InterestResult AccrueSimplePeriod( decimal priorAccrued, decimal notional, @@ -157,10 +147,6 @@ public static class FundingLegAccrual /// 复利多日计息(替换 CalcDailyCompoundInterest 的纯数学部分)。 /// 从 startDate 到 endDate 全程重放,每个重置日把累计利息并入本金。 /// - /// 本次平仓名义本金(已按 unwindFraction 缩放)。 - /// 末日重置时的存量利息(非末日传 0)。 - /// 历史已结利息(legacy: consumedInterest)。 - /// 平仓比例。 public static InterestResult AccrueCompoundPeriod( decimal notional, IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates, diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index d4696526..48ff75c2 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -1605,7 +1605,7 @@ namespace YLErp.Modules.SwapModule priorNotional: preEodPosition.TdInterestPrincipal, notional: posiPrincipal, unwindFraction: closePercent, - referenceNotional: orginPv, + baseNotional: orginPv, rate: legRate, policy: accrualPolicy, eodDate: endDate,