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