From 6a32181ef716580a176072c8309f573ac0987b58 Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 20 Aug 2026 11:51:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(swap):=20EQD-6977=20=E6=96=B0=E5=A2=9E=20P?= =?UTF-8?q?enalty=20=E7=BD=9A=E6=81=AF=E6=A8=A1=E5=9D=97=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=86=BB=E7=BB=93=E5=88=A9=E7=8E=87=E8=A7=A3=E6=9E=90=20+=20?= =?UTF-8?q?=E7=BD=9A=E6=81=AF=E7=AA=97=E5=8F=A3=E8=AE=A1=E6=81=AF=E5=99=A8?= =?UTF-8?q?=EF=BC=88=E5=A4=8D=E7=94=A8=20Accrual=20=E7=BA=AF=E5=87=BD?= =?UTF-8?q?=E6=95=B0=EF=BC=8C=E4=BA=A7=E5=87=BA=E5=90=8C=E6=9E=84=E7=BD=9A?= =?UTF-8?q?=E6=81=AF=E5=88=A9=E6=81=AF=E6=B5=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Penalty/PenaltyLegRateResolver.cs | 48 ++++++ .../Penalty/SwapPenaltyInterestCalculator.cs | 159 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 YLErpDAL/Modules/SwapModule/Penalty/PenaltyLegRateResolver.cs create mode 100644 YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs 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; + } +}