diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs new file mode 100644 index 00000000..b4e05448 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs @@ -0,0 +1,189 @@ +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Modules.SwapModule.Penalty; + +namespace UnitTestProject.Modules.SwapModule.Penalty +{ + /// + /// EQD-6977 罚息边界矩阵测试(全部断言金标准恒等式:全期 = 实结 + 罚息)。 + /// + /// 覆盖易错边界: + /// ① 平仓日恰为重置日(算尾/不算尾)——重置日快照基数还是上一段的,①须取 InterestIncomeSum; + /// ② 重置日前一日平仓(② 几乎整段、窗口首段 0 天); + /// ③ 到期日恰为重置日(末段 [到期,到期] 1 天); + /// ④ 锚点偏离(td.StartDate=7/31 但腿 PosiStartDate=8/3 的延期/存续腿——重置网格整体不同); + /// ⑤ 起息日当天平仓(无 preEod)。 + /// + /// 一致性前提(与现实世界对齐):冻结利率 = 当前重置区间(含 unwind-1 的区间)的在役利率, + /// 即"历史末段利率 = 冻结利率";历史各段定盘不同(体现真实 FR007 利率历史)。 + /// + [TestClass] + public class PenaltyBoundaryMatrixTest + { + private const decimal Notional = 100_000_000m; + private const int AnnualDays = 365; + private static readonly decimal[] Hist = { 0.0310m, 0.0420m, 0.0530m, 0.0225m }; // 7/31 / 8/7 / 8/14 / 8/21 段 + private static readonly decimal Frozen = Hist[^1]; // 冻结 = 当前区间在役利率 = 历史末段 + + /// 指定重置网格上的复利重放 [gridStart, end];超出所给历史段后沿用冻结利率。 + private static decimal AccrueOnGrid(DateTime gridStart, DateTime end, AccrualBoundary boundary, + decimal[] histRates, decimal notional = Notional, int period = 7) + { + var frozen = histRates[^1]; + var segs = new List<(DateTime, decimal)>(); + var i = 0; + for (var d = gridStart; d <= end; d = d.AddDays(period)) + segs.Add((d, i < histRates.Length ? histRates[i++] : frozen)); + return CompoundInterestAccrual.AccruePeriod( + notional: notional, segmentRates: segs, + startDate: gridStart, endDate: end, + boundary: boundary, annualDays: AnnualDays, isAnnualized: true, + resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m, + finalBasis: out _).Accrued; + } + + private static trade CreateTrade(DateTime startDate, DateTime maturity) + => new() + { + id = 1, TradeNumber = "UT-BOUNDARY", ClientId = 999998, + TradeType = "收益互换", TradeDate = startDate, StartDate = startDate, + ExerciseDate = maturity, TradeStatus = "确认成交", ValidState = "Valid" + }; + + private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread) + => new() + { + id = 1001, SwapTradeId = 1, PosiDirection = 0, InterestDirection = 1, + InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = spread, + InterestPrincipalFix = Notional, PosiStartDate = posiStart, PosiMatuirityDate = maturity, + IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利, + IsAnnualized = true, interest_rest_days = 7, interest_rule = 0, + FloatRateUnderlyingCode = null, InterestSwapInterval = "[]" + }; + + /// 日终快照:TdInterestPrincipal=当日实际滚动基数、InterestIncomeSum=截至当日待实现利息。 + private static eod_swap_position Snap(DateTime valueDate, decimal rollingBasis, decimal incomeSum) + => new() { id = 9, PositionId = 1001, ValueDate = valueDate, + TdInterestPrincipal = rollingBasis, InterestIncomeSum = incomeSum }; + + private static decimal RunFee(trade td, swap_position p, decimal settledAmount, + eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread) + { + var e = new swap_flow_event + { + PositionId = p.id, InterestAmount = settledAmount, InterestFee = 0m, + InterestDirection = 1, InterestClosePnL = settledAmount + }; + PenaltyInterestFeeMerger.Merge( + td, new List { p }, new List { e }, + unwind, AnnualDays, settled, maturityCalcLast: true, + posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, + getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread); + return e.InterestFee; + } + + [TestMethod] + public void 平仓日恰为重置日_算尾_恒等式成立() + { + var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 21); var maturity = new DateTime(2026, 8, 31); + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, Hist); // [7/31..8/21](末日=重置日,1 天) + var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, Hist); // 8/14 起段基数 + var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, Hist); // 8/20 待实现 + + // 前提自检:重置日快照基数(8/14段)≠今日应并入额(8/20待实现),旧公式(basis−P)必错——用例有鉴别力 + Assert.AreNotEqual((double)basisThru813, (double)incomeSum, 1000d, "快照基数与重置日应并入额应显著不同"); + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed, + Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: true, spread: Frozen); + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "重置日当天平仓(算尾):① 须取 InterestIncomeSum,全期=实结+罚息"); + } + + [TestMethod] + public void 平仓日恰为重置日_不算尾_恒等式成立() + { + var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 21); var maturity = new DateTime(2026, 8, 31); + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, Hist); // [7/31..8/20] + var incomeSum = elapsed; // 不算尾时实结=8/20待实现 + var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, Hist); + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed, + Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: false, spread: Frozen); + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "重置日当天平仓(不算尾):②=0,罚息含平仓日,全期=实结+罚息"); + } + + [TestMethod] + public void 重置日前一日平仓_段内几乎整段承接_恒等式成立() + { + var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 27); var maturity = new DateTime(2026, 8, 31); + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, Hist); // [7/31..8/27],段内已计 8/21..8/27 + var basisThru820 = AccrueOnGrid(start, new DateTime(2026, 8, 20), AccrualBoundary.Both, Hist); // 8/21 起段基数 + var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, Hist); + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed, + Snap(unwind.AddDays(-1), Notional + basisThru820, incomeSum), unwind, settled: true, spread: Frozen); + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "重置日前一日平仓:窗口首段 0 天、② 于 8/28 整段并入,全期=实结+罚息"); + } + + [TestMethod] + public void 到期日恰为重置日_末段一天_恒等式成立() + { + // 8/18 平仓:当前区间为 8/14 段(r3) → 冻结利率=r3=历史末段;到期 9/4 恰为重置日(末段 [9/4,9/4] 1 天) + var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 18); var maturity = new DateTime(2026, 9, 4); + var hist = new decimal[] { 0.0310m, 0.0420m, 0.0530m }; // 7/31 / 8/7 / 8/14(=冻结 5.3%) + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, hist); + var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, hist); + var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, hist); + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1]), elapsed, + Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: true, spread: hist[^1]); + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, hist); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "到期日=重置日:末段 [9/4,9/4] 1 天收尾,全期=实结+罚息"); + } + + [TestMethod] + public void 锚点偏离_延期腿按腿起息日网格_恒等式成立() + { + // 交易起始 7/31,但腿 PosiStartDate=8/3(延期/存续腿)→ 真实重置网格 8/10/8/17/8/24/8/31 + var tradeStart = new DateTime(2026, 7, 31); var posiStart = new DateTime(2026, 8, 3); + var unwind = new DateTime(2026, 8, 19); var maturity = new DateTime(2026, 9, 3); + var hist = new decimal[] { 0.0300m, 0.0400m, 0.0225m }; // 8/3 / 8/10 / 8/17(=冻结) 三段历史 + + var elapsed = AccrueOnGrid(posiStart, unwind, AccrualBoundary.Both, hist); + var basisThru816 = AccrueOnGrid(posiStart, new DateTime(2026, 8, 16), AccrualBoundary.Both, hist); // 8/17 起段基数 + var incomeSum = AccrueOnGrid(posiStart, unwind.AddDays(-1), AccrualBoundary.Both, hist); + + var fee = RunFee(CreateTrade(tradeStart, maturity), CompoundLeg(posiStart, maturity, hist[^1]), elapsed, + Snap(unwind.AddDays(-1), Notional + basisThru816, incomeSum), unwind, settled: true, spread: hist[^1]); + + var full = AccrueOnGrid(posiStart, maturity, AccrualBoundary.Both, hist); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "锚点偏离:罚息分段/重置日判定必须用 position.PosiStartDate 网格(误用 td.StartDate 网格必挂)"); + } + + [TestMethod] + public void 起息日当天平仓_无preEod_恒等式成立() + { + // 首日平仓:当前区间=首段(r1),无 preEod 时取价委托返回首段定盘 → 冻结利率=r1,全程恒率 + var start = new DateTime(2026, 7, 31); var maturity = new DateTime(2026, 8, 31); + var hist = new decimal[] { 0.0310m }; + var elapsed = AccrueOnGrid(start, start, AccrualBoundary.Both, hist); // 首日 1 天 + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1]), elapsed, + preEod: null, unwind: start, settled: true, spread: hist[^1]); + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, hist); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "起息日当天平仓:①=0、②=首日利息于 8/7 并入,全期=实结+罚息"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs index bb963be5..636481d8 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -89,15 +89,22 @@ public static class PenaltyInterestFeeMerger } // 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。 + // ① 的取值依赖平仓日是否为重置日(数据契约): + // 段中平仓:昨日快照 TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; + // 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 + // preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 decimal capitalized = 0m, carryIn = 0m; if (isCompound) { - var actualBasisShare = (preEod?.TdInterestPrincipal ?? 0m) * share; - capitalized = Math.Max(0m, actualBasisShare - closePrincipal); + var periodDays = position.interest_rest_days ?? 1; + var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays); + capitalized = unwindOnResetDay + ? (preEod?.InterestIncomeSum ?? 0m) * share + : Math.Max(0m, (preEod?.TdInterestPrincipal ?? 0m) * share - closePrincipal); // ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息) if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) { - trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:基数推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal)"); + trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); capitalized = Math.Max(0m, normalEvent.InterestAmount); } carryIn = normalEvent.InterestAmount - capitalized;