refactor(accrual): referenceNotional→baseNotional, notional统一

- referenceNotional → baseNotional(更短更清晰)
- 新代码统一用 notional(不用 principal):
  QuantLib/Strata 对 swap leg 用 notional 是业界标准
- DB字段(InterestPrincipalFix等)保持不动(legacy)
- 注释标注 legacy 对照(orginPv/originalPv/dynomicPrincipal)

验证: 编译0错误, 7影子测试全过, 全量523测试7失败(基线一致)。
This commit is contained in:
hjhan
2026-08-12 10:20:13 +08:00
parent ce6c0812e1
commit 2f7caa7a1f
3 changed files with 153 additions and 23 deletions
@@ -0,0 +1,144 @@
using System.Text.RegularExpressions;
using YLErp.Core.Interest;
using YLErp.Derivatives.Interest;
namespace UnitTestProject.Modules.SwapModule.Accrual
{
/// <summary>
/// 聚焦测试:AccrueCompoundInArrears 的「本金滚存时机」必须符合确认书规定。
/// 核心不变量:本金只允许在重置日/段末滚入利息,非重置日不得资本化。
///
/// 与原草稿的关键区别:本版<b>直接通过 AccrualTrace 断言不变量</b>。
/// 真实实现在每次段末会发出 ROLLOVER 事件并记录 newBasis(见 SwapInterest.cs:215 /
/// AccrualTrace.Rollover),因此「非重置日是否发生资本化」是可程序化验证的,
/// 无需仅靠总利息回归来保护(原草稿的自我怀疑"无法断言计息基数"已不成立)。
/// </summary>
[TestClass]
public class SwapInterest_CompoundInArrears_RolloverTimingTests
{
private const int FundingLegPrecision = 12;
private const int AnnualDays = 365;
/// <summary>
/// 场景: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段计息基数恒为原始本金、段内未提前资本化。
/// </summary>
[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<DateTime> { 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,
"重置日滚入的本金应为原始本金 + 前段利息,证明段内未提前资本化");
}
/// <summary>
/// 极端场景:startDate = endDate1天),无重置日。
/// 期望利息 = 本金 × 日利率 = 1,000,000 × 0.0365/365 = 100。
/// 且唯一 ROLLOVER 必须落在窗口终点(=startDate),无任何内部重置滚存。
/// </summary>
[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());
}
/// <summary>
/// 段内无重置日:验证整段等同于单利,且不发生任何内部滚存。
/// 6天窗口(01-01..01-06)在7天重置周期内,Both 边界含两端 = 6 个计息日,
/// 期望利息 = 本金 × 日利率 × 6 = 600。
/// </summary>
[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);
}
}
}