From ed86402aba43c414ea0b729ad131580069355e2b Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 20 Aug 2026 11:51:25 +0800 Subject: [PATCH] =?UTF-8?q?test(swap):=20EQD-6977=20=E7=BD=9A=E6=81=AF?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E9=92=89=E6=AD=BB=E2=80=94=E2=80=94=E9=87=91?= =?UTF-8?q?=E6=A0=87=E5=87=86=E6=81=92=E7=AD=89=E5=BC=8F(=E5=85=A8?= =?UTF-8?q?=E6=9C=9F=3D=E5=B7=B2=E7=BB=93+=E7=BD=9A=E6=81=AF,=E5=90=AB?= =?UTF-8?q?=E4=B8=8D=E7=AE=97=E5=B0=BE/=E4=B8=AD=E6=AE=B5=E5=B9=B3?= =?UTF-8?q?=E4=BB=93=E5=8F=8C=E6=89=BF=E6=8E=A5=E9=87=8F)=20+=20=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E5=9B=9B=E8=B1=A1=E9=99=90=20+=20=E9=83=A8=E5=88=86?= =?UTF-8?q?=E5=B9=B3=E4=BB=93=20+=20=E5=86=BB=E7=BB=93=E5=8F=96=E7=8E=87?= =?UTF-8?q?=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Penalty/PenaltyLegRateResolverTest.cs | 81 +++++++ .../SwapPenaltyInterestCalculatorTest.cs | 213 ++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 UnitTestProject/Modules/SwapModule/Penalty/PenaltyLegRateResolverTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculatorTest.cs diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyLegRateResolverTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyLegRateResolverTest.cs new file mode 100644 index 00000000..c81d60e6 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyLegRateResolverTest.cs @@ -0,0 +1,81 @@ +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Modules.SwapModule.Penalty; + +namespace UnitTestProject.Modules.SwapModule.Penalty +{ + /// + /// EQD-6977 罚息冻结利率解析契约测试。 + /// 规则(需求 2.2.2):冻结为「最后一个重置区间」定盘;终止日为重置日也取上一区间。 + /// + [TestClass] + public class PenaltyLegRateResolverTest + { + private const decimal Spread = 0.05m; // +500bp + private static readonly DateTime UnwindDate = new(2026, 8, 25); + + private static swap_position CreateFloatPosition(int interestRule = 0) + => new() + { + id = 1001, SwapTradeId = 1, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.支付, + InterestMode = (int)InterestModeEnum.标的期初全价, + InterestRateDefault = Spread, + PosiStartDate = new DateTime(2026, 7, 31), + interest_rest_days = 7, interest_rule = interestRule, + FloatRateUnderlyingCode = "FR007", + FloatRate = 0.0185m + }; + + [TestMethod] + public void 浮动腿_preEod快照优先_重置日下午仍取上一区间() + { + // 8/25 为重置日且下午已出新价的边缘场景:preEod.FloatRate(昨日区间定盘)仍优先, + // 解析器不做任何取价——「终止日取上一区间」由快照语义天然覆盖。 + var p = CreateFloatPosition(); + var rate = PenaltyLegRateResolver.ResolveFrozenRate( + p, spread: Spread, preEodFloatRate: 0.0210m, + unwindDate: UnwindDate, tryGetFixing: _ => throw new AssertFailedException("preEod 在场时不应取价")); + + Assert.AreEqual(Spread + 0.0210m, rate.AllInRate, "冻结 all-in = 利差 + 上一区间定盘"); + } + + [TestMethod] + public void 浮动腿_无preEod_按前一营业日取价日取定盘() + { + var p = CreateFloatPosition(interestRule: 0); // 当前营业日规则 + DateTime? askedDate = null; + var rate = PenaltyLegRateResolver.ResolveFrozenRate( + p, spread: Spread, preEodFloatRate: null, + unwindDate: UnwindDate, + tryGetFixing: d => { askedDate = d; return 0.0195m; }); + + Assert.AreEqual(new DateTime(2026, 8, 24), askedDate, "取价日 = GetFixingDate(8/24, rule=0)"); + Assert.AreEqual(Spread + 0.0195m, rate.AllInRate); + } + + [TestMethod] + public void 浮动腿_无preEod_缺价抛异常() + { + var p = CreateFloatPosition(); + Assert.ThrowsException(() => + PenaltyLegRateResolver.ResolveFrozenRate( + p, spread: Spread, preEodFloatRate: null, + unwindDate: UnwindDate, tryGetFixing: _ => null)); + } + + [TestMethod] + public void 固定腿_不取价_直接固定利率() + { + var p = CreateFloatPosition(); + p.FloatRateUnderlyingCode = null; + + var rate = PenaltyLegRateResolver.ResolveFrozenRate( + p, spread: Spread, preEodFloatRate: null, + unwindDate: UnwindDate, tryGetFixing: _ => throw new AssertFailedException("固定腿不应取价")); + + Assert.AreEqual(Spread, rate.AllInRate); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculatorTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculatorTest.cs new file mode 100644 index 00000000..ed053ddc --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculatorTest.cs @@ -0,0 +1,213 @@ +using YLErp; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Modules.SwapModule.Penalty; + +namespace UnitTestProject.Modules.SwapModule.Penalty +{ + /// + /// EQD-6977 平仓罚息计算器契约测试。 + /// + /// 金标准恒等式(需求核心语义"利息端盈亏等同于持有至到期"): + /// 全期利息 = 平仓日已结利息 + 罚息窗口利息 + /// 历史口径:7/31 起息、8/31 到期、7 天重置(8/7/8/14/8/21/8/28)、8/25 提前终止 + /// (平仓日落在 8/21–8/28 重置段中间——复利承接两分量的关键场景)。 + /// + [TestClass] + public class SwapPenaltyInterestCalculatorTest + { + 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-EQD6977", ClientId = 999998, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = MaturityDate, TradeStatus = "确认成交", ValidState = "Valid" + }; + + private static swap_position CreatePosition(InterestTypeEnum interestType, SwapDirectionEnum direction) + => new() + { + id = 1001, SwapTradeId = 1, PosiDirection = 0, + InterestDirection = (int)direction, + 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 = "[]" + }; + + private static AccrualPolicy Policy(swap_position p) + => AccrualPolicy.BuildEod(p, AnnualDays, p.InterestType == (int)InterestTypeEnum.复利); + + /// 常率复利重放 [7/31, endDate],重置段 = 每 7 天。 + private static decimal CompoundAccruedTo(DateTime endDate, AccrualBoundary boundary) + { + var segs = new List<(DateTime, decimal)>(); + for (var d = StartDate; d <= endDate; d = d.AddDays(7)) segs.Add((d, Rate)); + 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; + } + + private static swap_flow_event CalcCompoundPenalty( + swap_position p, decimal closePrincipal, bool settled, decimal capitalized, decimal carryIn) + => SwapPenaltyInterestCalculator.CalcPenalty( + CreateTrade(), p, closePrincipal: closePrincipal, + unwindDate: UnwindDate, maturityDate: MaturityDate, + unwindDaySettled: settled, maturityCalcLast: true, + capitalizedInterest: capitalized, carryInInterest: carryIn, + frozenRate: FundingLegRate.Fixed(Rate), + policy: Policy(p), resetAnchor: StartDate, + eventType: (int)SwapEventTypeEnum.平仓, valueDate: UnwindDate); + + [TestMethod] + public void 金标准恒等式_复利_全期等于已结加罚息() + { + var p = CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付); + var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both); + var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both); + var carryIn = elapsed - capitalized; + + var e = CalcCompoundPenalty(p, Notional, settled: true, capitalized, carryIn); + + var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both); + Assert.AreEqual((double)full, (double)(elapsed + e.InterestAmount), 0.0001, + $"全期({full}) 应等于 已结({elapsed}) + 罚息({e.InterestAmount});承接①={capitalized} ②={carryIn}"); + } + + [TestMethod] + public void 金标准恒等式_复利_不算尾平仓日() + { + // 不算尾:正常结算未计 8/25 → 罚息含 8/25(IncludeStart=true),承接②少一天 + var p = CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付); + var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.StartOnly); + var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both); + var carryIn = elapsed - capitalized; + + var e = CalcCompoundPenalty(p, Notional, settled: false, capitalized, carryIn); + + var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both); + Assert.AreEqual((double)full, (double)(elapsed + e.InterestAmount), 0.0001, + "不算尾时罚息窗口须补回平仓日,恒等式仍成立"); + } + + [TestMethod] + public void 单利固定腿_剩余期限利息等于公式() + { + // 需求 2.2.1:剩余利息 = 固定 × 名义本金 × 剩余天数 / 计息基准 + // 算尾平仓日 + 到期算尾:窗口 (8/25, 8/31] = 6 天 + var e = SwapPenaltyInterestCalculator.CalcPenalty( + CreateTrade(), CreatePosition(InterestTypeEnum.单利, SwapDirectionEnum.支付), + closePrincipal: Notional, + unwindDate: UnwindDate, maturityDate: MaturityDate, + unwindDaySettled: true, maturityCalcLast: true, + capitalizedInterest: 0m, carryInInterest: 0m, + frozenRate: FundingLegRate.Fixed(Rate), + policy: Policy(CreatePosition(InterestTypeEnum.单利, SwapDirectionEnum.支付)), + resetAnchor: StartDate, + eventType: (int)SwapEventTypeEnum.平仓, valueDate: UnwindDate); + + var expected = Rate * Notional * 6m / AnnualDays; + Assert.AreEqual((double)expected, (double)e.InterestAmount, 0.0001, "6 天 = 8/26..8/31"); + } + + [TestMethod] + public void 边界四象限_剩余天数口径正确() + { + var p = CreatePosition(InterestTypeEnum.单利, SwapDirectionEnum.支付); + var td = CreateTrade(); + // 8/26..8/31 共 6 个计息日候选;IncludeStart 加 8/25、IncludeEnd 加 8/31 由约定裁剪 + var cases = new (bool settled, bool calcLast, int days)[] + { + (true, true, 6), // (8/25, 8/31] 8/26..8/31 + (true, false, 5), // (8/25, 8/31) 8/26..8/30 + (false, true, 7), // [8/25, 8/31] 8/25..8/31 + (false, false, 6), // [8/25, 8/31) 8/25..8/30 + }; + foreach (var (settled, calcLast, days) in cases) + { + var e = SwapPenaltyInterestCalculator.CalcPenalty( + td, p, closePrincipal: Notional, + unwindDate: UnwindDate, maturityDate: MaturityDate, + unwindDaySettled: settled, maturityCalcLast: calcLast, + capitalizedInterest: 0m, carryInInterest: 0m, + frozenRate: FundingLegRate.Fixed(Rate), + policy: Policy(p), resetAnchor: StartDate, + eventType: (int)SwapEventTypeEnum.平仓, valueDate: UnwindDate); + var expected = Rate * Notional * days / AnnualDays; + Assert.AreEqual((double)expected, (double)e.InterestAmount, 0.0001, + $"settled={settled}, calcLast={calcLast} → {days} 天"); + } + } + + [TestMethod] + public void 部分平仓_仅被平份额计罚息() + { + var p = CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付); + var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both); + var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both); + var carryIn = elapsed - capitalized; + + // 被平 30%:本金与两承接量同比缩放,罚息应恰为全额的 30% + var full = CalcCompoundPenalty(p, Notional, true, capitalized, carryIn); + var partial = CalcCompoundPenalty(p, Notional * 0.3m, true, capitalized * 0.3m, carryIn * 0.3m); + + Assert.AreEqual((double)(full.InterestAmount * 0.3m), (double)partial.InterestAmount, 0.0001, + "被平 30%(本金与承接量同比)罚息应恰为全额的 30%"); + } + + [TestMethod] + public void 事件字段_与正常利息流同构_罚息原因与方向盈亏() + { + var e = SwapPenaltyInterestCalculator.CalcPenalty( + CreateTrade(), CreatePosition(InterestTypeEnum.单利, SwapDirectionEnum.支付), + closePrincipal: Notional, + unwindDate: UnwindDate, maturityDate: MaturityDate, + unwindDaySettled: true, maturityCalcLast: true, + capitalizedInterest: 0m, carryInInterest: 0m, + frozenRate: FundingLegRate.Fixed(Rate), + policy: Policy(CreatePosition(InterestTypeEnum.单利, SwapDirectionEnum.支付)), + resetAnchor: StartDate, + eventType: (int)SwapEventTypeEnum.平仓, valueDate: UnwindDate); + + Assert.AreEqual(SwapPenaltyInterestCalculator.PenaltyEventReason, e.EventReason, "事件原因=罚息"); + Assert.AreEqual((int)SwapEventTypeEnum.平仓, e.EventType, "事件类型=平仓(下游聚合无差别)"); + Assert.AreEqual(UnwindDate, e.UnwindDate, "UnwindDate=平仓日(不伪造成到期日)"); + Assert.AreEqual((int)SwapFlowDateStateEnum.完成, e.DataState); + Assert.AreEqual((double)e.InterestAmount, (double)(-e.InterestClosePnL), 0.0001, + "支付方向:InterestClosePnL = InterestAmount × (-1)"); + } + + [TestMethod] + public void 零剩余期限_金额为零() + { + var e = SwapPenaltyInterestCalculator.CalcPenalty( + CreateTrade(), CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付), + closePrincipal: Notional, + unwindDate: MaturityDate, maturityDate: MaturityDate, + unwindDaySettled: true, maturityCalcLast: true, + capitalizedInterest: 90_000m, carryInInterest: 10_000m, + frozenRate: FundingLegRate.Fixed(Rate), + policy: Policy(CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付)), + resetAnchor: StartDate, + eventType: (int)SwapEventTypeEnum.平仓, valueDate: MaturityDate); + Assert.AreEqual(0m, e.InterestAmount, "平仓日=到期日无剩余期限,罚息为 0(承接量不产生利息)"); + } + } +}