diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs
new file mode 100644
index 00000000..b4e05448
--- /dev/null
+++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyBoundaryMatrixTest.cs
@@ -0,0 +1,189 @@
+using YLErp.Modules.SwapModule.Accrual;
+using YLErp.Modules.SwapModule.Penalty;
+
+namespace UnitTestProject.Modules.SwapModule.Penalty
+{
+ ///
+ /// EQD-6977 罚息边界矩阵测试(全部断言金标准恒等式:全期 = 实结 + 罚息)。
+ ///
+ /// 覆盖易错边界:
+ /// ① 平仓日恰为重置日(算尾/不算尾)——重置日快照基数还是上一段的,①须取 InterestIncomeSum;
+ /// ② 重置日前一日平仓(② 几乎整段、窗口首段 0 天);
+ /// ③ 到期日恰为重置日(末段 [到期,到期] 1 天);
+ /// ④ 锚点偏离(td.StartDate=7/31 但腿 PosiStartDate=8/3 的延期/存续腿——重置网格整体不同);
+ /// ⑤ 起息日当天平仓(无 preEod)。
+ ///
+ /// 一致性前提(与现实世界对齐):冻结利率 = 当前重置区间(含 unwind-1 的区间)的在役利率,
+ /// 即"历史末段利率 = 冻结利率";历史各段定盘不同(体现真实 FR007 利率历史)。
+ ///
+ [TestClass]
+ public class PenaltyBoundaryMatrixTest
+ {
+ private const decimal Notional = 100_000_000m;
+ private const int AnnualDays = 365;
+ private static readonly decimal[] Hist = { 0.0310m, 0.0420m, 0.0530m, 0.0225m }; // 7/31 / 8/7 / 8/14 / 8/21 段
+ private static readonly decimal Frozen = Hist[^1]; // 冻结 = 当前区间在役利率 = 历史末段
+
+ /// 指定重置网格上的复利重放 [gridStart, end];超出所给历史段后沿用冻结利率。
+ private static decimal AccrueOnGrid(DateTime gridStart, DateTime end, AccrualBoundary boundary,
+ decimal[] histRates, decimal notional = Notional, int period = 7)
+ {
+ var frozen = histRates[^1];
+ var segs = new List<(DateTime, decimal)>();
+ var i = 0;
+ for (var d = gridStart; d <= end; d = d.AddDays(period))
+ segs.Add((d, i < histRates.Length ? histRates[i++] : frozen));
+ return CompoundInterestAccrual.AccruePeriod(
+ notional: notional, segmentRates: segs,
+ startDate: gridStart, endDate: end,
+ boundary: boundary, annualDays: AnnualDays, isAnnualized: true,
+ resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m,
+ finalBasis: out _).Accrued;
+ }
+
+ private static trade CreateTrade(DateTime startDate, DateTime maturity)
+ => new()
+ {
+ id = 1, TradeNumber = "UT-BOUNDARY", ClientId = 999998,
+ TradeType = "收益互换", TradeDate = startDate, StartDate = startDate,
+ ExerciseDate = maturity, TradeStatus = "确认成交", ValidState = "Valid"
+ };
+
+ private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread)
+ => new()
+ {
+ id = 1001, SwapTradeId = 1, PosiDirection = 0, InterestDirection = 1,
+ InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = spread,
+ InterestPrincipalFix = Notional, PosiStartDate = posiStart, PosiMatuirityDate = maturity,
+ IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利,
+ IsAnnualized = true, interest_rest_days = 7, interest_rule = 0,
+ FloatRateUnderlyingCode = null, InterestSwapInterval = "[]"
+ };
+
+ /// 日终快照:TdInterestPrincipal=当日实际滚动基数、InterestIncomeSum=截至当日待实现利息。
+ private static eod_swap_position Snap(DateTime valueDate, decimal rollingBasis, decimal incomeSum)
+ => new() { id = 9, PositionId = 1001, ValueDate = valueDate,
+ TdInterestPrincipal = rollingBasis, InterestIncomeSum = incomeSum };
+
+ private static decimal RunFee(trade td, swap_position p, decimal settledAmount,
+ eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread)
+ {
+ var e = new swap_flow_event
+ {
+ PositionId = p.id, InterestAmount = settledAmount, InterestFee = 0m,
+ InterestDirection = 1, InterestClosePnL = settledAmount
+ };
+ PenaltyInterestFeeMerger.Merge(
+ td, new List { p }, new List { e },
+ unwind, AnnualDays, settled, maturityCalcLast: true,
+ posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m,
+ getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread);
+ return e.InterestFee;
+ }
+
+ [TestMethod]
+ public void 平仓日恰为重置日_算尾_恒等式成立()
+ {
+ var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 21); var maturity = new DateTime(2026, 8, 31);
+ var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, Hist); // [7/31..8/21](末日=重置日,1 天)
+ var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, Hist); // 8/14 起段基数
+ var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, Hist); // 8/20 待实现
+
+ // 前提自检:重置日快照基数(8/14段)≠今日应并入额(8/20待实现),旧公式(basis−P)必错——用例有鉴别力
+ Assert.AreNotEqual((double)basisThru813, (double)incomeSum, 1000d, "快照基数与重置日应并入额应显著不同");
+
+ var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed,
+ Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: true, spread: Frozen);
+
+ var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist);
+ Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
+ "重置日当天平仓(算尾):① 须取 InterestIncomeSum,全期=实结+罚息");
+ }
+
+ [TestMethod]
+ public void 平仓日恰为重置日_不算尾_恒等式成立()
+ {
+ var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 21); var maturity = new DateTime(2026, 8, 31);
+ var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, Hist); // [7/31..8/20]
+ var incomeSum = elapsed; // 不算尾时实结=8/20待实现
+ var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, Hist);
+
+ var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed,
+ Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: false, spread: Frozen);
+
+ var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist);
+ Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
+ "重置日当天平仓(不算尾):②=0,罚息含平仓日,全期=实结+罚息");
+ }
+
+ [TestMethod]
+ public void 重置日前一日平仓_段内几乎整段承接_恒等式成立()
+ {
+ var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 27); var maturity = new DateTime(2026, 8, 31);
+ var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, Hist); // [7/31..8/27],段内已计 8/21..8/27
+ var basisThru820 = AccrueOnGrid(start, new DateTime(2026, 8, 20), AccrualBoundary.Both, Hist); // 8/21 起段基数
+ var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, Hist);
+
+ var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed,
+ Snap(unwind.AddDays(-1), Notional + basisThru820, incomeSum), unwind, settled: true, spread: Frozen);
+
+ var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist);
+ Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
+ "重置日前一日平仓:窗口首段 0 天、② 于 8/28 整段并入,全期=实结+罚息");
+ }
+
+ [TestMethod]
+ public void 到期日恰为重置日_末段一天_恒等式成立()
+ {
+ // 8/18 平仓:当前区间为 8/14 段(r3) → 冻结利率=r3=历史末段;到期 9/4 恰为重置日(末段 [9/4,9/4] 1 天)
+ var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 18); var maturity = new DateTime(2026, 9, 4);
+ var hist = new decimal[] { 0.0310m, 0.0420m, 0.0530m }; // 7/31 / 8/7 / 8/14(=冻结 5.3%)
+ var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, hist);
+ var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, hist);
+ var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, hist);
+
+ var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1]), elapsed,
+ Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: true, spread: hist[^1]);
+
+ var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, hist);
+ Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
+ "到期日=重置日:末段 [9/4,9/4] 1 天收尾,全期=实结+罚息");
+ }
+
+ [TestMethod]
+ public void 锚点偏离_延期腿按腿起息日网格_恒等式成立()
+ {
+ // 交易起始 7/31,但腿 PosiStartDate=8/3(延期/存续腿)→ 真实重置网格 8/10/8/17/8/24/8/31
+ var tradeStart = new DateTime(2026, 7, 31); var posiStart = new DateTime(2026, 8, 3);
+ var unwind = new DateTime(2026, 8, 19); var maturity = new DateTime(2026, 9, 3);
+ var hist = new decimal[] { 0.0300m, 0.0400m, 0.0225m }; // 8/3 / 8/10 / 8/17(=冻结) 三段历史
+
+ var elapsed = AccrueOnGrid(posiStart, unwind, AccrualBoundary.Both, hist);
+ var basisThru816 = AccrueOnGrid(posiStart, new DateTime(2026, 8, 16), AccrualBoundary.Both, hist); // 8/17 起段基数
+ var incomeSum = AccrueOnGrid(posiStart, unwind.AddDays(-1), AccrualBoundary.Both, hist);
+
+ var fee = RunFee(CreateTrade(tradeStart, maturity), CompoundLeg(posiStart, maturity, hist[^1]), elapsed,
+ Snap(unwind.AddDays(-1), Notional + basisThru816, incomeSum), unwind, settled: true, spread: hist[^1]);
+
+ var full = AccrueOnGrid(posiStart, maturity, AccrualBoundary.Both, hist);
+ Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
+ "锚点偏离:罚息分段/重置日判定必须用 position.PosiStartDate 网格(误用 td.StartDate 网格必挂)");
+ }
+
+ [TestMethod]
+ public void 起息日当天平仓_无preEod_恒等式成立()
+ {
+ // 首日平仓:当前区间=首段(r1),无 preEod 时取价委托返回首段定盘 → 冻结利率=r1,全程恒率
+ var start = new DateTime(2026, 7, 31); var maturity = new DateTime(2026, 8, 31);
+ var hist = new decimal[] { 0.0310m };
+ var elapsed = AccrueOnGrid(start, start, AccrualBoundary.Both, hist); // 首日 1 天
+
+ var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1]), elapsed,
+ preEod: null, unwind: start, settled: true, spread: hist[^1]);
+
+ var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, hist);
+ Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
+ "起息日当天平仓:①=0、②=首日利息于 8/7 并入,全期=实结+罚息");
+ }
+ }
+}
diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs
index bb963be5..636481d8 100644
--- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs
+++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs
@@ -89,15 +89,22 @@ public static class PenaltyInterestFeeMerger
}
// 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。
+ // ① 的取值依赖平仓日是否为重置日(数据契约):
+ // 段中平仓:昨日快照 TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差;
+ // 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取
+ // preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。
decimal capitalized = 0m, carryIn = 0m;
if (isCompound)
{
- var actualBasisShare = (preEod?.TdInterestPrincipal ?? 0m) * share;
- capitalized = Math.Max(0m, actualBasisShare - closePrincipal);
+ var periodDays = position.interest_rest_days ?? 1;
+ var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays);
+ capitalized = unwindOnResetDay
+ ? (preEod?.InterestIncomeSum ?? 0m) * share
+ : Math.Max(0m, (preEod?.TdInterestPrincipal ?? 0m) * share - closePrincipal);
// ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息)
if (capitalized > Math.Max(0m, normalEvent.InterestAmount))
{
- trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:基数推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal)");
+ trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)");
capitalized = Math.Max(0m, normalEvent.InterestAmount);
}
carryIn = normalEvent.InterestAmount - capitalized;