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(承接量不产生利息)"); } } }