From 6e76f1c10d1a7823696a8ba176ea2354ab7b6c57 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 07:10:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(swap):=20EQD-6977=20=E6=97=A0preEod?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E2=91=A0=3D0=E4=B8=94=E8=B4=A6=E9=BE=84?= =?UTF-8?q?=E8=B6=85=E9=87=8D=E7=BD=AE=E5=91=A8=E6=9C=9F=E8=A1=A5=E9=80=80?= =?UTF-8?q?=E5=8C=96=E5=91=8A=E8=AD=A6trace=E2=80=94=E2=80=94interestWindo?= =?UTF-8?q?wEmpty(=E5=BD=93=E6=97=A5=E5=B7=B2=E7=BB=93=E6=81=AF)=E6=97=A9?= =?UTF-8?q?=E9=80=80=E4=B8=8D=E9=87=8D=E6=94=BE=E8=A6=86=E7=9B=96=E7=A7=8D?= =?UTF-8?q?=E5=AD=90=E5=80=BC=E6=97=B6=E2=91=A0=E9=9D=99=E9=BB=980(?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E6=97=A5=E5=88=86=E6=94=AF=E6=9C=AC=E6=9C=89?= =?UTF-8?q?=E5=90=8C=E7=B1=BB=E5=91=8A=E8=AD=A6,=E6=AE=B5=E4=B8=AD?= =?UTF-8?q?=E5=88=86=E6=94=AF=E6=BC=8F)=E5=A4=8D=E5=88=A9=E6=AF=8F?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E5=B9=B6=E6=9C=AC=E7=90=86=E5=BA=94=E2=91=A0?= =?UTF-8?q?>0=EF=BC=9Btrace=E5=90=AB=E4=BA=8B=E4=BB=B6=E5=9F=BA=E6=95=B0/?= =?UTF-8?q?=E5=B9=B3=E4=BB=93=E6=9C=AC=E9=87=91=E4=B8=A4=E4=BE=A7=E5=AF=B9?= =?UTF-8?q?=E6=AF=94=E5=80=BC+=E8=B4=A6=E9=BE=84=E5=A4=A9=E6=95=B0+?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E5=91=A8=E6=9C=9F+=E7=96=91=E4=BC=BC?= =?UTF-8?q?=E6=88=90=E5=9B=A0=E6=8C=87=E5=90=91(interestWindowEmpty?= =?UTF-8?q?=E7=A7=8D=E5=AD=90=E6=9C=AA=E9=87=8D=E6=94=BE/=E6=97=A5?= =?UTF-8?q?=E7=BB=88=E5=BD=92=E6=A1=A3=E7=BC=BA=E5=A4=B1)=E8=B6=B3?= =?UTF-8?q?=E5=A4=9F=E4=BA=8B=E5=90=8E=E5=AE=9A=E4=BD=8D=E6=A0=B9=E5=9B=A0?= =?UTF-8?q?=EF=BC=9B=E6=89=BF=E6=8E=A5=E5=9D=97=E6=8A=BD=E4=B8=BAResolveCo?= =?UTF-8?q?mpoundCarry=E7=A7=81=E6=9C=89=E7=BA=AF=E6=96=B9=E6=B3=95(Merge?= =?UTF-8?q?=E7=98=A6=E8=BA=AB,=E4=B8=89=E5=88=86=E6=94=AF=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=A5=91=E7=BA=A6=E6=B3=A8=E9=87=8A=E9=9A=8F=E8=A1=8C?= =?UTF-8?q?,=E8=A1=8C=E4=B8=BA=E9=9B=B6=E5=8F=98=E5=8C=96)=EF=BC=9BTDD?= =?UTF-8?q?=E5=85=88=E8=A1=8C3=E4=BE=8Btrace=E9=92=89=E5=AD=90:=E9=80=80?= =?UTF-8?q?=E5=8C=96=E5=BF=85=E5=91=8A=E8=AD=A6(=E5=85=88=E7=BA=A2)/?= =?UTF-8?q?=E5=85=9C=E5=BA=95>0=E4=B8=8D=E5=91=8A=E8=AD=A6/=E7=9C=9F?= =?UTF-8?q?=E9=A6=96=E6=97=A5=E4=B8=8D=E5=91=8A=E8=AD=A6=EF=BC=9B=E7=BD=9A?= =?UTF-8?q?=E6=81=AF=E5=9F=9F27=E4=BE=8B+GetInterests=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E8=AF=AD=E4=B9=899=E4=BE=8B=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Penalty/PenaltyInterestFeeMergerTest.cs | 45 +++++++++- .../Penalty/PenaltyInterestFeeMerger.cs | 86 ++++++++++++------- 2 files changed, 96 insertions(+), 35 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs index 2186bfdc..5df336c4 100644 --- a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs @@ -50,7 +50,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty private static void RunMerge( swap_position p, swap_flow_event normalEvent, eod_swap_position? preEod, - Func? getSpread = null, Func? tryGetFixing = null) + Func? getSpread = null, Func? tryGetFixing = null, + AccrualTrace? trace = null) { getSpread ??= _ => Rate; tryGetFixing ??= (d, code) => Rate; @@ -61,7 +62,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, getSpread: getSpread, getPreEod: _ => preEod, - tryGetFixing: tryGetFixing); + tryGetFixing: tryGetFixing, + trace: trace); } /// 复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。 @@ -152,6 +154,45 @@ namespace UnitTestProject.Modules.SwapModule.Penalty Assert.IsTrue(e.InterestFee > 0m, "无 preEod(首日平仓等)仍可计算罚息"); } + [TestMethod] + public void 无preEod复利段中兜底为零且账龄超重置周期_留退化告警trace() + { + // 场景:无日终快照 + 复利 + 段中平仓,事件 InterestPrincipal 仍是种子值(=平仓本金)→兜底①=0。 + // 账龄 25 天 ≥ 7 天重置周期:复利每周期并本理应①>0,①=0 属退化—— + // 典型成因=interestWindowEmpty(当日已结息)早退未重放覆盖种子、或日终归档缺失。 + var e = NormalEvent(settledAmount: 50_000m); + e.InterestPrincipal = Notional; // GetInterests 种子值:interestWindowEmpty 早退路径不会用重放基数覆盖它 + var trace = new AccrualTrace(); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null, trace: trace); + + StringAssert.Contains(trace.ToString(), "无preEod兜底①=0", + "①=0 且账龄超周期必须留告警,供事后核对日终归档/计息窗口根因"); + } + + [TestMethod] + public void 无preEod兜底为正_不留退化告警() + { + var e = NormalEvent(settledAmount: 50_000m); + e.InterestPrincipal = Notional + 100_000m; // 重放末次并本金后基数 → ①=100000 正常路径 + var trace = new AccrualTrace(); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null, trace: trace); + + Assert.IsFalse(trace.ToString().Contains("兜底①=0"), "①>0 是正常兜底路径,不得告警"); + } + + [TestMethod] + public void 无preEod真首日兜底为零_不留退化告警() + { + var p = Leg(InterestTypeEnum.复利); + p.PosiStartDate = UnwindDate; // 起息日当天平仓:账龄 0 < 重置周期,①=0 是设计内约定(类头注) + var e = NormalEvent(settledAmount: 50_000m); + e.InterestPrincipal = Notional; + var trace = new AccrualTrace(); + RunMerge(p, e, preEod: null, trace: trace); + + Assert.IsFalse(trace.ToString().Contains("兜底①=0"), "真首日 ①=0 合法,不得告警"); + } + [TestMethod] public void 冻结利率解析失败_跳过该腿不阻断() { diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs index a6b2256b..91ab256f 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -93,39 +93,9 @@ public static class PenaltyInterestFeeMerger } // 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。 - // ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约): - // 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; - // 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest) - // 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值; - // 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 - // preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 - decimal capitalized = 0m, carryIn = 0m; - if (isCompound) - { - var periodDays = position.interest_rest_days ?? 1; - var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays); - 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)) - { - trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); - capitalized = Math.Max(0m, normalEvent.InterestAmount); - } - carryIn = normalEvent.InterestAmount - capitalized; - } + var (capitalized, carryIn) = isCompound + ? ResolveCompoundCarry(position, normalEvent, preEod, closePrincipal, share, unwindDate, trace) + : (0m, 0m); var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound); // 锚点 = PosiStartDate:与正常计息重放(CalcDailyCompoundInterest 的分段网格)一致,延期腿勿用 td.StartDate @@ -149,4 +119,54 @@ public static class PenaltyInterestFeeMerger $"罚息={penalty:F2} → InterestFee {feeBefore:F2}→{normalEvent.InterestFee:F2} PnL含罚息={normalEvent.InterestClosePnL:F2}"); } } + + /// + /// 复利承接量:① 实际滚动基数中已并入部分 + ② 最近重置日后实际已计利息(= 实结 − ①)。 + /// + /// ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约): + /// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; + /// 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest) + /// 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值; + /// 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 + /// preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 + /// + private static (decimal Capitalized, decimal CarryIn) ResolveCompoundCarry( + swap_position position, swap_flow_event normalEvent, eod_swap_position? preEod, + decimal closePrincipal, decimal share, DateTime unwindDate, AccrualTrace? trace) + { + var periodDays = position.interest_rest_days ?? 1; + var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays); + + var capitalized = 0m; + 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); + // 兜底①=0 但账龄已过重置周期:复利每周期并本,理应 ①>0——多为 interestWindowEmpty + // (当日已结息)早退未重放覆盖种子值、或日终归档缺失。留痕含两侧基数与账龄,供直接定位根因。 + var ageDays = (unwindDate - position.PosiStartDate).Days; + if (capitalized == 0m && ageDays >= periodDays) + trace?.Note( + $"PENALTY|p{position.id} 注意 无preEod兜底①=0但账龄{ageDays}天≥重置周期{periodDays}天:" + + $"事件基数{normalEvent.InterestPrincipal:F2}=平仓本金{closePrincipal:F2}(疑似interestWindowEmpty种子未重放/日终归档缺失," + + $"请核对swap_flow_event.InterestPrincipal重放回写与eod_swap_position归档)"); + } + + // ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息) + if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) + { + trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); + capitalized = Math.Max(0m, normalEvent.InterestAmount); + } + return (capitalized, normalEvent.InterestAmount - capitalized); + } }