diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyLegRateResolver.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyLegRateResolver.cs
new file mode 100644
index 00000000..cb7bed57
--- /dev/null
+++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyLegRateResolver.cs
@@ -0,0 +1,48 @@
+using YLErp.Derivatives.Interest;
+using YLErp.Modules.SwapModule.Accrual;
+
+namespace YLErp.Modules.SwapModule.Penalty;
+
+///
+/// EQD-6977 罚息冻结利率解析(纯函数)。
+///
+/// 规则(需求 2.2.2):剩余期限利率冻结为「最后一个重置区间」的 FR007 定盘值——
+/// 终止日恰为重置日且下午已出新价时,仍取上一重置区间(边缘场景显式落地)。
+///
+/// 冻结来源优先级:
+/// 1. preEod.FloatRate——上一日终快照即昨日「实际在役」利率(GetFloatRate 非重置日正是沿用它),
+/// 天然覆盖重置日下午边缘;且避开 td.StartDate / PosiStartDate 双锚点推导(见 GetFloatDate 锚点注记);
+/// 2. 无 preEod(首日平仓等):取价日 = GetFixingDate(unwindDate-1)(-1 所在重置区间的定盘,
+/// interest_rule 0=当前营业日/-1=前一营业日由 IndexFixerBase 统一处理)。
+/// 固定腿利率本即冻结,直接 Fixed;剩余期限的加点利差由调用方按 SwapIntervalList 取 as-of 平仓日值传入。
+///
+public static class PenaltyLegRateResolver
+{
+ ///
+ /// 解析罚息窗口的冻结 all-in 利率。
+ ///
+ /// 利息腿(融资腿,非保证金)
+ /// 加点利差(调用方按 SwapIntervalList 取 as-of unwindDate 值,同 GetFixedRate 口径)
+ /// 上一日终快照 FloatRate;无 preEod 传 null
+ /// 提前终止日
+ /// 定盘取价委托(测试可注入);入参=取价日,无价返回 null
+ public static FundingLegRate ResolveFrozenRate(
+ swap_position position,
+ decimal spread,
+ decimal? preEodFloatRate,
+ DateTime unwindDate,
+ Func tryGetFixing)
+ {
+ if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode))
+ return FundingLegRate.Fixed(spread);
+
+ if (preEodFloatRate.HasValue)
+ return FundingLegRate.Floating(spread, preEodFloatRate.Value);
+
+ var fixingDate = IndexFixerBase.GetFixingDate(unwindDate.AddDays(-1), position.interest_rule);
+ var fixing = tryGetFixing(fixingDate);
+ if (!fixing.HasValue)
+ throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格");
+ return FundingLegRate.Floating(spread, fixing.Value);
+ }
+}
diff --git a/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs b/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs
new file mode 100644
index 00000000..1f07df45
--- /dev/null
+++ b/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs
@@ -0,0 +1,159 @@
+using YLErp.DBModels.Enums;
+using YLErp.Modules.SwapModule.Accrual;
+using YLErp.Modules.SwapModule.ReturnLegs;
+
+namespace YLErp.Modules.SwapModule.Penalty;
+
+///
+/// EQD-6977 平仓罚息计算器(纯函数)。
+///
+/// 语义:提前终止时把利息端计息窗口从「平仓日」延长到「原始到期日」,剩余期限按冻结利率计息,
+/// 使利息端盈亏等同于持有至到期。金标准恒等式:
+///
+/// 全期利息 = 平仓日已结利息(正常平仓流) + 罚息窗口利息(本方法)
+///
+/// 边界规格(需求 2.2 / 评审 8.2,经金标准恒等式测试钉死):
+/// - IncludeStart = !unwindDaySettled:正常结算已计平仓日(算尾)→ 罚息自次日起;不算尾 → 含平仓日;
+/// - IncludeEnd = maturityCalcLast:到期日沿用交易自身算尾约定(非本次平仓的 newCalcLast);
+/// - 复利承接(平仓日落在重置段中间时与全期轨迹逐日对齐的两个量):
+/// capitalizedInterest = 已并入最近重置日的累计利息 → 窗口首日起即加入计息基数
+/// (全期轨迹中当前重置段的滚动基数 = 本金 + 该量,段内每一天都在其上计息);
+/// carryInInterest = 最近重置日之后已计至平仓日的利息 → 首个窗口重置日并入并持续留在基数。
+/// 二者之和 = 被平部分的平仓日已结利息(正常平仓流 InterestAmount)。
+///
+/// 产物:与正常利息流同构的 swap_flow_event(EventReason="罚息"),下游结算/报表无差别消费。
+/// 仅融资腿;保证金腿(MarginModes)与浮动端 P&L 不进入本模块。
+/// IsPenaltyInterest 列标记待 DB 迁移(阶段1)落地后由接缝层写入。
+///
+public static class SwapPenaltyInterestCalculator
+{
+ /// 罚息利息流的事件原因(兼作判别字段,存量事件恒为"交易")。
+ public const string PenaltyEventReason = "罚息";
+
+ ///
+ /// 计算罚息窗口 [unwindDate, maturityDate] 的利息流。
+ ///
+ /// 交易(SwapTradeId/SwapTradeNo/ClientId 载体)
+ /// 被平的融资腿
+ /// 被平部分计息本金(部分平仓仅算被平份额)
+ /// 复利承接①:已并入最近重置日的累计利息(被平份额),窗口首日起即入基数;单利传 0
+ /// 复利承接②:最近重置日后已计至平仓日的利息(被平份额),首个窗口重置日并入;单利传 0
+ /// 提前终止日(窗口起点)
+ /// 合约原始到期日(窗口终点,= td.ExerciseDate)
+ /// 正常平仓利息是否已计平仓日(effectiveCalcLast = calcLast || newCalcLast)
+ /// 交易到期日算尾约定(tradeExtend.CalcLast)
+ /// 复利承接:被平部分已计至平仓日利息(正常平仓流 InterestAmount);单利传 0
+ /// 冻结利率(PenaltyLegRateResolver.ResolveFrozenRate 产物)
+ /// 计息政策(单复利/重置周期/年化天数;Convention 由本方法覆盖)
+ /// 重置日锚点(对齐 GetFloatRate 的 td.StartDate 口径;锚点二义性注记见其声明)
+ /// 事件类型(平仓)
+ /// 操作日期
+ /// 计息轨迹(可选,SwapCalcTrace 落盘)
+ public static swap_flow_event CalcPenalty(
+ trade td,
+ swap_position position,
+ decimal closePrincipal,
+ DateTime unwindDate,
+ DateTime maturityDate,
+ bool unwindDaySettled,
+ bool maturityCalcLast,
+ decimal capitalizedInterest,
+ decimal carryInInterest,
+ FundingLegRate frozenRate,
+ AccrualPolicy policy,
+ DateTime resetAnchor,
+ int eventType,
+ DateTime valueDate,
+ AccrualTrace? trace = null)
+ {
+ var boundary = AccrualBoundary.Of(includeStart: !unwindDaySettled, includeEnd: maturityCalcLast);
+ var allInRate = frozenRate.AllInRate;
+
+ decimal amount;
+ decimal finalBasis;
+ if (policy.IsCompound)
+ {
+ // 复利:即使利率冻结为单值,也须按重置日分段(并本金发生在分段边界),每段同一冻结利率。
+ // notional = 本金 + 已并入最近重置日的利息:全期轨迹中当前重置段的滚动基数,
+ // 段内(含罚息窗口首段)每一天都在其上计息——平仓日落在段中间时与全期逐日对齐的关键。
+ var segments = BuildFrozenSegments(unwindDate, maturityDate, policy.ResetPeriodDays, resetAnchor, allInRate);
+ var r = CompoundInterestAccrual.AccruePeriod(
+ notional: closePrincipal + capitalizedInterest,
+ segmentRates: segments,
+ startDate: unwindDate,
+ endDate: maturityDate,
+ boundary: boundary,
+ annualDays: policy.AnnualDays,
+ isAnnualized: policy.IsAnnualized,
+ resetCarryInterest: 0m,
+ realizedInterest: 0m,
+ unwindFraction: 1m,
+ finalBasis: out finalBasis,
+ trace: trace,
+ carryInInterest: carryInInterest);
+ amount = r.Accrued;
+ }
+ else
+ {
+ // 单利:无并本金语义,冻结利率即单段全程
+ var r = SimpleInterestAccrual.AccruePeriod(
+ priorAccrued: 0m,
+ notional: closePrincipal,
+ unwindFraction: 1m,
+ segmentRates: new List<(DateTime StartDate, decimal Rate)> { (unwindDate, allInRate) },
+ startDate: unwindDate,
+ endDate: maturityDate,
+ priorValueDate: unwindDate.AddDays(-1),
+ boundary: boundary,
+ annualDays: policy.AnnualDays,
+ isAnnualized: policy.IsAnnualized,
+ trace: trace);
+ amount = r.Accrued;
+ finalBasis = closePrincipal;
+ }
+
+ var rounded = InterestMath.Round(amount, InterestMath.FundingLegPrecision);
+ var interest = new swap_flow_event
+ {
+ SwapTradeId = td.id,
+ SwapTradeNo = td.TradeNumber,
+ EventType = eventType,
+ EventReason = PenaltyEventReason,
+ EventDate = valueDate,
+ UnwindDate = unwindDate,
+ PositionId = position.id,
+ InterestDirection = position.InterestDirection,
+ InterestRate = allInRate,
+ InterestPrincipal = finalBasis,
+ InterestSwapInterval = position.InterestSwapInterval,
+ InterestMode = position.InterestMode,
+ FloatRate = string.IsNullOrEmpty(position.FloatRateUnderlyingCode) ? position.FloatRate : allInRate,
+ DataState = (int)SwapFlowDateStateEnum.完成,
+ ClientId = td.ClientId,
+ InterestAmount = rounded,
+ TdInterestAmount = rounded
+ };
+ interest.InterestClosePnL = interest.InterestAmount * DirectionRatio.ReceivePay(position.InterestDirection);
+ return interest;
+ }
+
+ ///
+ /// 复利冻结分段:段边界 = 窗口内重置日((d - anchor) % period == 0,对齐 IsResetDay 公式),
+ /// 每段填同一冻结利率。首段必为 (unwindDate, rate);[start,end] 含端点的重置日也生成段
+ /// (末段起点==到期日时由 AccruePeriod 的边界决定是否计息)。
+ ///
+ private static List<(DateTime StartDate, decimal Rate)> BuildFrozenSegments(
+ DateTime start, DateTime end, int periodDays, DateTime anchor, decimal rate)
+ {
+ if (periodDays <= 1)
+ return new List<(DateTime, decimal)> { (start, rate) };
+
+ var segments = new List<(DateTime StartDate, decimal Rate)> { (start, rate) };
+ for (var d = start.AddDays(1); d <= end; d = d.AddDays(1))
+ {
+ if (SwapDealService.IsResetDay(d, anchor, periodDays))
+ segments.Add((d, rate));
+ }
+ return segments;
+ }
+}