diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs index b4e05448..6ebb8bf0 100644 --- a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs @@ -49,14 +49,14 @@ namespace UnitTestProject.Modules.SwapModule.Penalty ExerciseDate = maturity, TradeStatus = "确认成交", ValidState = "Valid" }; - private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread) + private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread, int periodDays = 7) => 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, + IsAnnualized = true, interest_rest_days = periodDays, interest_rule = 0, FloatRateUnderlyingCode = null, InterestSwapInterval = "[]" }; @@ -66,16 +66,18 @@ namespace UnitTestProject.Modules.SwapModule.Penalty 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) + eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread, + decimal interestPrincipal = 0m, bool maturityCalcLast = true) { var e = new swap_flow_event { PositionId = p.id, InterestAmount = settledAmount, InterestFee = 0m, - InterestDirection = 1, InterestClosePnL = settledAmount + InterestDirection = 1, InterestClosePnL = settledAmount, + InterestPrincipal = interestPrincipal // 复利主路径下=重放末次并本金后基数(=被平份额本金+①) }; PenaltyInterestFeeMerger.Merge( td, new List { p }, new List { e }, - unwind, AnnualDays, settled, maturityCalcLast: true, + unwind, AnnualDays, settled, maturityCalcLast: maturityCalcLast, posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread); return e.InterestFee; @@ -170,6 +172,26 @@ namespace UnitTestProject.Modules.SwapModule.Penalty "锚点偏离:罚息分段/重置日判定必须用 position.PosiStartDate 网格(误用 td.StartDate 网格必挂)"); } + [TestMethod] + public void 无preEod且此前已有重置_经事件基数兜底_恒等式精确成立() + { + // UAT 实测场景(tradeId=2447):环境无日终快照、起息后已发生 8/19 重置并本。 + // 兜底① = normalEvent.InterestPrincipal − 本金(复利重放末次并本金后基数); + // 修复前 ①=0 少算 ≈3.17 元(并入额×冻结利率×段尾天数),本用例钉死兜底路径的精确性。 + var start = new DateTime(2026, 8, 5); var unwind = new DateTime(2026, 8, 20); var maturity = new DateTime(2026, 9, 30); + var hist = new decimal[] { 0.0216m, 0.0144m }; // 8/5 段 2.16% / 8/19 段 1.44%(=冻结),14 天重置 + var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, hist, period: 14); // 已结 [8/5..8/19] + var replayFinalBasis = Notional + AccrueOnGrid(start, new DateTime(2026, 8, 18), AccrualBoundary.Both, hist, period: 14); // 8/19 重置并本后基数 + + var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1], periodDays: 14), elapsed, + preEod: null, unwind: unwind, settled: false, spread: hist[^1], + interestPrincipal: replayFinalBasis, maturityCalcLast: false); // 不算尾合约、14天重置(对应 UAT tradeId=2447 口径) + + var full = AccrueOnGrid(start, maturity, AccrualBoundary.StartOnly, hist, period: 14); + Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01, + "无preEod+已有重置:兜底取事件基数后 ① 精确,全期=实结+罚息(修复前差≈3.17元)"); + } + [TestMethod] public void 起息日当天平仓_无preEod_恒等式成立() { diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs index 636481d8..7ca04a77 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -89,8 +89,10 @@ public static class PenaltyInterestFeeMerger } // 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。 - // ① 的取值依赖平仓日是否为重置日(数据契约): - // 段中平仓:昨日快照 TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; + // ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约): + // 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; + // 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest) + // 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值; // 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 // preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 decimal capitalized = 0m, carryIn = 0m; @@ -98,9 +100,20 @@ public static class PenaltyInterestFeeMerger { 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 (unwindOnResetDay) + { + capitalized = (preEod?.InterestIncomeSum ?? 0m) * share; + if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays) + trace?.Note($"PENALTY|p{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)"); + } + else if (preEod != null) + { + capitalized = Math.Max(0m, preEod.TdInterestPrincipal * share - closePrincipal); + } + else + { + capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal); + } // ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息) if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) {