using YLErp.Modules.SwapModule.Accrual; using YLErp.Modules.SwapModule.Penalty; namespace UnitTestProject.Modules.SwapModule.Penalty { /// /// EQD-6977 罚息接缝 headless 测试(无 DB:spread/preEod/取价 全部以委托注入)。 /// 锁定:Merge 把罚息金额并入既有利息事件的 InterestFee(不新增事件、不改 InterestAmount); /// 承接量取实际计息状态(preEod 基数 + 事件实结金额)——含【多区间不同定盘】恒等式钉死, /// 该用例在"冻结利率重放推导承接量"的旧实现下必挂(FR007 真实利率历史场景)。 /// [TestClass] public class PenaltyInterestFeeMergerTest { private const decimal Notional = 100_000_000m; private const decimal Rate = 0.0225m; // 冻结 all-in 年化 private const int AnnualDays = 365; private static readonly DateTime StartDate = new(2026, 7, 31); private static readonly DateTime MaturityDate = new(2026, 8, 31); private static readonly DateTime UnwindDate = new(2026, 8, 25); private static readonly DateTime LastResetBeforeUnwind = new(2026, 8, 21); private static trade CreateTrade() => new() { id = 1, TradeNumber = "UT-MERGE", ClientId = 999998, TradeType = "收益互换", StartDate = StartDate, TradeDate = StartDate, ExerciseDate = MaturityDate, TradeStatus = "确认成交", ValidState = "Valid" }; private static swap_position Leg(InterestTypeEnum interestType) => new() { id = 1001, SwapTradeId = 1, PosiDirection = 0, InterestDirection = 1, InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = Rate, InterestPrincipalFix = Notional, PosiStartDate = StartDate, PosiMatuirityDate = MaturityDate, IsInitial = true, Invalid = false, InterestType = (int)interestType, IsAnnualized = true, interest_rest_days = 7, interest_rule = 0, FloatRateUnderlyingCode = null, InterestSwapInterval = "[]" }; /// 正常平仓利息流(模拟 GetInterests 产出):InterestAmount=实结利息、InterestFee=0。 private static swap_flow_event NormalEvent(decimal settledAmount) => new() { PositionId = 1001, InterestAmount = settledAmount, InterestFee = 0m, InterestDirection = 1, InterestClosePnL = settledAmount }; // 模拟 GetInterests 已算好的 PnL(收取=+1) private static eod_swap_position PreEod(decimal rollingBasis, decimal floatRate = 0m) => new() { id = 9, PositionId = 1001, ValueDate = UnwindDate.AddDays(-1), TdInterestPrincipal = rollingBasis, FloatRate = floatRate }; private static void RunMerge( swap_position p, swap_flow_event normalEvent, eod_swap_position? preEod, Func? getSpread = null, Func? tryGetFixing = null, AccrualTrace? trace = null) { getSpread ??= _ => Rate; tryGetFixing ??= (d, code) => Rate; PenaltyInterestFeeMerger.Merge( CreateTrade(), new List { p }, new List { normalEvent }, UnwindDate, AnnualDays, unwindDaySettled: true, maturityCalcLast: true, posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, getSpread: getSpread, getPreEod: _ => preEod, tryGetFixing: tryGetFixing, trace: trace); } /// 复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。 private static decimal CompoundAccruedTo(DateTime endDate, AccrualBoundary boundary, params decimal[] rates) { var segs = new List<(DateTime, decimal)>(); var i = 0; for (var d = StartDate; d <= endDate; d = d.AddDays(7)) // 超出所给历史段后沿用最后区间利率——即“未来段冻结为最后区间利率”的语义(勿循环回绕) segs.Add((d, rates.Length == 1 ? rates[0] : i < rates.Length ? rates[i++] : rates[^1])); return CompoundInterestAccrual.AccruePeriod( notional: Notional, segmentRates: segs, startDate: StartDate, endDate: endDate, boundary: boundary, annualDays: AnnualDays, isAnnualized: true, resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m, finalBasis: out _).Accrued; } [TestMethod] public void 单利固定腿_罚息并入InterestFee_不新增事件() { var e = NormalEvent(settledAmount: 50_000m); RunMerge(Leg(InterestTypeEnum.单利), e, preEod: PreEod(Notional)); Assert.AreEqual(0d, (double)(e.InterestFee - Rate * Notional * 6m / AnnualDays), 0.0001, "罚息=利率×本金×6天/基准(窗口 (8/25, 8/31])"); Assert.AreEqual(50_000d, (double)e.InterestAmount, 0.0001, "正常实结利息不受影响"); Assert.AreEqual((double)(50_000m + e.InterestFee), (double)e.InterestClosePnL, 0.0001, "PnL=(实结+罚息)×方向(收取=+1)"); } [TestMethod] public void 浮动腿_取价委托解析冻结率_并入费用() { var p = Leg(InterestTypeEnum.单利); p.FloatRateUnderlyingCode = "FR007"; var e = NormalEvent(settledAmount: 50_000m); // 无 preEod → 走取价委托:all-in = spread(0) + 定盘(Rate) RunMerge(p, e, preEod: null, getSpread: _ => 0m, tryGetFixing: (d, code) => Rate); Assert.AreEqual(0d, (double)(e.InterestFee - Rate * Notional * 6m / AnnualDays), 0.0001, "浮动腿冻结率=取价委托值(零利差)"); } [TestMethod] public void 复利常率_承接取实际状态_恒等式全期等于已结加罚息() { // 实际计息状态:preEod 滚动基数 = P + 已并入利息(截至 8/20);事件实结 = elapsed([7/31,8/25] Both) var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both, Rate); var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both, Rate); var e = NormalEvent(elapsed); RunMerge(Leg(InterestTypeEnum.复利), e, preEod: PreEod(Notional + capitalized)); var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both, Rate); Assert.AreEqual((double)full, (double)(elapsed + e.InterestFee), 0.0001, "常率下 全期 = 已结(事件实结) + 罚息(InterestFee)"); } [TestMethod] public void 复利多区间不同定盘_承接取实际状态_恒等式仍成立() { // 真实 FR007 世界:四个历史重置区间定盘各不相同,冻结利率=最后区间(2.25%) var r1 = 0.0310m; var r2 = 0.0420m; var r3 = 0.0530m; var r4 = Rate; // r4=0.0225 冻结值 var rates = new[] { r1, r2, r3, r4 }; // 实际计息状态(与 GetInterests 重放同源): var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both, rates); // 已并入 8/21 重置日 var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both, rates); // 实结(含 8/21..8/25 段内利息) var e = NormalEvent(elapsed); RunMerge(Leg(InterestTypeEnum.复利), e, preEod: PreEod(Notional + capitalized)); // 全期参照:历史段按各自真实定盘、8/28 起的未来段按冻结利率(=r4,恰好同段延续) var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both, rates); Assert.AreEqual((double)full, (double)(elapsed + e.InterestFee), 0.01, "多区间不同定盘下 全期(历史实率+未来冻结) = 实结 + 罚息——承接量必须来自实际状态"); // 反证旧缺陷:冻结重放推导的承接①(全程 r4)≠ 实际①(分段实率),差额显著 var frozenReplayCapitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both, Rate); Assert.AreNotEqual((double)capitalized, (double)frozenReplayCapitalized, 1000d, "前提自检:分段实率与冻结重放的已并入利息应显著不同(否则用例失去鉴别力)"); } [TestMethod] public void 复利无preEod_承接退化为实结全额_可计算不崩溃() { var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both, Rate); var e = NormalEvent(elapsed); RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null); 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 冻结利率解析失败_跳过该腿不阻断() { var p = Leg(InterestTypeEnum.单利); p.FloatRateUnderlyingCode = "FR007"; var e = NormalEvent(settledAmount: 50_000m); RunMerge(p, e, preEod: null, getSpread: _ => 0m, tryGetFixing: (d, code) => null); Assert.AreEqual(0m, e.InterestFee, "缺价跳过:不加罚息、不抛异常"); Assert.AreEqual(50_000d, (double)e.InterestAmount, 0.0001, "正常平仓不受影响"); } } }