diff --git a/UnitTestProject/Modules/SwapModule/Accrual/CompoundCarryInTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/CompoundCarryInTest.cs new file mode 100644 index 00000000..8c4af6da --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Accrual/CompoundCarryInTest.cs @@ -0,0 +1,107 @@ +using Newtonsoft.Json; +using YLErp; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Accrual; + +namespace UnitTestProject.Modules.SwapModule.Accrual +{ + /// + /// EQD-6977 carryInInterest 契约测试: + /// 1) 默认 0 与旧逐日循环逐位一致(加参零行为变化的安全证明); + /// 2) carry-in 仅在【首个重置日】并入计息基数(非窗口首日起息)—— + /// 与"持有至到期"全期轨迹对齐的数学不变量:增量 = carryIn × 后续段日利率 × 后续段天数。 + /// + [TestClass] + public class CompoundCarryInTest + { + private const decimal Notional = 100_000_000m; + private const decimal Spread = 0.0025m; + private const int AnnualDays = 365; + private static readonly DateTime StartDate = new(2026, 4, 21); + private static readonly DateTime EndDate = new(2026, 5, 11); // 21天 = 3×7,末日是重置日 + + private static swap_position CreatePosition() + { + return new swap_position + { + id = 1001, SwapTradeId = 1, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.标的期初全价, + InterestRateDefault = Spread, + InterestPrincipalFix = Notional, + PosiStartDate = StartDate, PosiMatuirityDate = StartDate.AddYears(1), + IsInitial = true, Invalid = false, + InterestType = (int)InterestTypeEnum.复利, + IsAnnualized = true, interest_rest_days = 7, interest_rule = 0, + FloatRateUnderlyingCode = null, + InterestSwapInterval = "[]" + }; + } + + private static List<(DateTime, decimal)> Segments() + => new() + { + (StartDate, Spread), + (StartDate.AddDays(7), Spread), + (StartDate.AddDays(14), Spread), + }; + + private sealed class StubSvc : SwapDealService + { + public StubSvc() : base(new OptUserInfo(0, nameof(CompoundCarryInTest), OptUserFrom.UnitTest)) { } + } + + [TestMethod] + public void carryIn_默认省略_与旧逐日循环一致() + { + var position = CreatePosition(); + var flowEvent = new swap_flow_event { InterestRate = Spread }; + + decimal oldI = 0, oldTd = 0; + new StubSvc().CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent, + AnnualDays, 0m, 1m, true, false, ref oldI, ref oldTd); + + // 省略 carryInInterest(默认 0) + var r1 = CompoundInterestAccrual.AccruePeriod( + notional: Notional, segmentRates: Segments(), + startDate: StartDate, endDate: EndDate, + boundary: AccrualBoundary.StartOnly, annualDays: AnnualDays, isAnnualized: true, + resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m, + finalBasis: out _); + // 显式传 0 与省略等价 + var r2 = CompoundInterestAccrual.AccruePeriod( + notional: Notional, segmentRates: Segments(), + startDate: StartDate, endDate: EndDate, + boundary: AccrualBoundary.StartOnly, annualDays: AnnualDays, isAnnualized: true, + resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m, + finalBasis: out _, trace: null, carryInInterest: 0m); + + Assert.AreEqual((double)oldI, (double)r1.Accrued, 0.0000001, "省略 carryIn 与旧实现一致"); + Assert.AreEqual((double)r1.Accrued, (double)r2.Accrued, 0.0000001, "省略与显式0一致"); + } + + [TestMethod] + public void carryIn_仅在首重置日起息_增量等于后续两段复利() + { + const decimal carryIn = 1_000_000m; + + decimal Accrued(decimal c) + => CompoundInterestAccrual.AccruePeriod( + notional: Notional, segmentRates: Segments(), + startDate: StartDate, endDate: EndDate, + boundary: AccrualBoundary.Both, annualDays: AnnualDays, isAnnualized: true, + resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m, + finalBasis: out _, trace: null, carryInInterest: c).Accrued; + + var delta = Accrued(carryIn) - Accrued(0m); + + // Both 边界下三段各 7 天。carryIn 于 4/28(首个重置日)并入基数: + // 首段 [4/21,4/28] 不含 carryIn;其后两段 carryIn 自身起息且其首段利息再复利。 + // 精确增量 = c×d + (c + c×d)×d = c×(2d + d²) = c×((1+d)² − 1),d = 7天利率因子。 + var d = Spread * 7m / AnnualDays; + var expected = carryIn * (2m * d + d * d); + Assert.AreEqual((double)expected, (double)delta, 0.001, + "carryIn 增量 = 首个重置日起息的两段复利,首段不含 carryIn"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs b/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs index eddbb07a..9a6b09fd 100644 --- a/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs +++ b/YLErpDAL/Modules/SwapModule/Accrual/CompoundInterestAccrual.cs @@ -61,6 +61,11 @@ public static class CompoundInterestAccrual /// 复利多日计息(替换 CalcDailyCompoundInterest 的纯数学部分)。 /// 从 startDate 到 endDate 全程重放,每个重置日把累计利息并入本金。 /// + /// + /// 窗口前已计未结转利息(EQD-6977 罚息承接):在【首个重置日】并入计息基数—— + /// 与"持有至到期"全期轨迹严格对齐(恒等式:全期复利 = 平仓日已结利息 + 罚息窗口利息)。 + /// 默认 0 时与旧行为逐位一致。与 resetCarryInterest 互斥使用(后者是全平重放的末段存量替代)。 + /// public static InterestResult AccruePeriod( decimal notional, IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates, @@ -73,10 +78,14 @@ public static class CompoundInterestAccrual decimal realizedInterest, decimal unwindFraction, out decimal finalBasis, - AccrualTrace? trace = null) + AccrualTrace? trace = null, + decimal carryInInterest = 0m) { decimal accrualBasis = notional; - decimal accrued = 0m; + // carryInInterest 是"窗口前已计未结转利息":先并入 accrued,随首个重置日的 + // basis = notional + accrued 一并资本化,并在其后每个重置日持续留在基数里 + //(与全窗口重放时 accrued 含全部历史利息的轨迹严格一致);最终报告时扣除。 + decimal accrued = carryInInterest; trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized); @@ -116,6 +125,9 @@ public static class CompoundInterestAccrual finalBasis = accrualBasis; + // 报告口径只含窗口内增量(carryIn 是窗口前已结利息,由正常平仓流单独结算) + accrued -= carryInInterest; + if (realizedInterest != 0m) trace?.Unwind(endDate, unwindFraction, realizedInterest * unwindFraction, accrued - realizedInterest * unwindFraction); accrued -= realizedInterest * unwindFraction;