From 35a84cd60d8ec818ba929eb291a8d73edfc27a6c Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 20 Aug 2026 13:23:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(swap):=20EQD-6977=20=E7=BD=9A=E6=81=AF?= =?UTF-8?q?=E8=AE=A1=E6=81=AF=E6=8E=A5=E7=BC=9D=E2=80=94=E2=80=94GetIntrad?= =?UTF-8?q?ayUnwindInterests=20=E6=8C=89=20IsPenaltyInterest=20=E5=9C=A8?= =?UTF-8?q?=20GetInterests=20=E8=BF=94=E5=9B=9E=E5=90=8E=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E5=90=8C=E6=9E=84=E7=BD=9A=E6=81=AF=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SwapModule/Accrual/AccrualTrace.cs | 6 +- .../Penalty/PenaltyInterestAppender.cs | 83 +++++++++++++++++++ .../Penalty/SwapPenaltyInterestCalculator.cs | 44 ++++++++++ .../Modules/SwapModule/SwapDealService.cs | 24 +++++- 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestAppender.cs diff --git a/YLErpDAL/Modules/SwapModule/Accrual/AccrualTrace.cs b/YLErpDAL/Modules/SwapModule/Accrual/AccrualTrace.cs index 271a0bc4..4e73dfb8 100644 --- a/YLErpDAL/Modules/SwapModule/Accrual/AccrualTrace.cs +++ b/YLErpDAL/Modules/SwapModule/Accrual/AccrualTrace.cs @@ -67,6 +67,10 @@ public sealed class AccrualTrace => Add(AccrualTraceEvent.End, default, $"END accrued={totalAccrued:F6} today={totalToday:F6}"); + /// 自由文本注解(如罚息接缝的诊断行),不绑定特定计息语义。 + public void Note(string message) + => Add(AccrualTraceEvent.Note, default, message); + private void Add(AccrualTraceEvent step, DateTime date, string line) => _entries.Add(new AccrualTraceEntry(step, date, line)); @@ -78,7 +82,7 @@ public sealed class AccrualTrace /// 追踪条目的语义类别(对应 QuantLib/Strata 的"事件"概念),便于程序化筛选(如"只看重置日")。 public enum AccrualTraceEvent { - Start, DayAccrual, ResetBefore, ResetAfter, Rollover, Unwind, End + Start, DayAccrual, ResetBefore, ResetAfter, Rollover, Unwind, End, Note } /// 单条追踪记录:类别 + 日期 + 已渲染文本。 diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestAppender.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestAppender.cs new file mode 100644 index 00000000..948845af --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestAppender.cs @@ -0,0 +1,83 @@ +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Modules.SwapModule.FundingLegs; + +namespace YLErp.Modules.SwapModule.Penalty; + +/// +/// EQD-6977 罚息接缝(纯函数,不含 DB 依赖):把罚息利息流追加进盘中平仓利息列表。 +/// +/// 设计:上帝类(SwapDealService.GetIntradayUnwindInterests)仅需把三个外部依赖以委托注入—— +/// getSpread(加点利差)/ getPreEodFloatRate(上一日终快照利率)/ tryGetFixing(定盘取价), +/// 本类自身零 DB 耦合、可 headless 单测。罚息事件经 CalcPenalty 产出,置 IsPenaltyInterest=1。 +/// +/// 仅作用融资腿(调用方已预过滤保证金腿 MarginModes);浮动端 P&L 不进入。 +/// 取不到冻结利率(浮动腿缺价且无 preEod)时跳过该腿罚息(不阻断正常平仓),并留 trace。 +/// +public static class PenaltyInterestAppender +{ + public static void Append( + trade td, + List fundingPositions, + List interests, + DateTime unwindDate, + int annualDays, + bool unwindDaySettled, + bool maturityCalcLast, + decimal posiNotionalValue, + decimal closePosiNotionalValue, + decimal closePercent, + Func getSpread, + Func getPreEodFloatRate, + Func tryGetFixing, + AccrualTrace? trace = null) + { + if (td.ExerciseDate == null) + { + trace?.Note("PENALTY|跳过 交易无到期日(ExerciseDate=null)"); + return; + } + var maturityDate = td.ExerciseDate.Value; + var resetAnchor = td.StartDate ?? td.TradeDate ?? unwindDate; + var eventType = (int)SwapEventTypeEnum.平仓; + + foreach (var position in fundingPositions) + { + // 复用 GetInterests 的本金口径(mode2 无条件覆盖 / mode9 全平兜底,见其根因位置注释) + var mode = (InterestModeEnum)position.InterestMode; + var r = FundingLegStrategyFactory.Get(mode) + .CalcNotional(position.InterestPrincipalFix, posiNotionalValue, closePercent); + decimal closePrincipal = r.ClosePrincipal; + if (mode == InterestModeEnum.合约名义本金规模 + || (mode == InterestModeEnum.标的期初全价 && posiNotionalValue == 0m)) + { + closePrincipal = closePosiNotionalValue; + } + + FundingLegRate frozenRate; + try + { + Func posTryGetFixing = d => tryGetFixing(d, position.FloatRateUnderlyingCode); + frozenRate = PenaltyLegRateResolver.ResolveFrozenRate( + position, getSpread(position), getPreEodFloatRate(position), unwindDate, posTryGetFixing); + } + catch (Exception ex) + { + trace?.Note($"PENALTY|跳过 p{position.id} 冻结利率解析失败:{ex.Message}"); + continue; + } + + var policy = AccrualPolicy.BuildEod(position, annualDays, position.InterestType == (int)InterestTypeEnum.复利); + var (capitalized, carryIn) = SwapPenaltyInterestCalculator.ComputeCarryBreakdown( + position, unwindDate, resetAnchor, frozenRate, policy, closePrincipal); + + var penalty = SwapPenaltyInterestCalculator.CalcPenalty( + td, position, closePrincipal, unwindDate, maturityDate, + unwindDaySettled, maturityCalcLast, capitalized, carryIn, + frozenRate, policy, resetAnchor, eventType, unwindDate); + penalty.IsPenaltyInterest = 1; + interests.Add(penalty); + + trace?.Note($"PENALTY|p{position.id} 完成 本金={closePrincipal:F2} 承接 已资本化={capitalized:F4} 段内={carryIn:F4} 罚息={penalty.InterestAmount:F2}"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs b/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs index b85fa4af..fd41a03e 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs @@ -171,4 +171,48 @@ public static class SwapPenaltyInterestCalculator } return segments; } + + /// + /// 复利承接量推导(冻结率重放,自洽的“冻结率持有至到期”语义,与金标准测试模型一致): + /// capitalizedInterest = 已并入最近重置日的累计利息 → 窗口首日起即入基数; + /// carryInInterest = 最近重置日之后已计至平仓日的利息 → 首个窗口重置日并入。 + /// 二者之和 = 被平部分平仓日已结利息(正常平仓流 InterestAmount 在冻结率下的重放值)。 + /// 重放采用与 AccrueCompound 相同的 BuildFrozenSegments + AccruePeriod,保证轨迹严格一致。 + /// 单利(无重置)时窗口无 carry:capitalized=全段利息、carryIn=0。 + /// + public static (decimal Capitalized, decimal CarryIn) ComputeCarryBreakdown( + swap_position position, DateTime unwindDate, DateTime resetAnchor, + FundingLegRate frozenRate, AccrualPolicy policy, decimal closePrincipal) + { + if (policy.ResetPeriodDays <= 1) + { + var full = AccrueFrom(resetAnchor, unwindDate, frozenRate, policy, closePrincipal, AccrualBoundary.Both); + return (full, 0m); + } + var lastReset = LastResetBefore(unwindDate, resetAnchor, policy.ResetPeriodDays); + var elapsed = AccrueFrom(resetAnchor, unwindDate, frozenRate, policy, closePrincipal, AccrualBoundary.Both); + var capitalized = AccrueFrom(resetAnchor, lastReset.AddDays(-1), frozenRate, policy, closePrincipal, AccrualBoundary.Both); + return (capitalized, elapsed - capitalized); + } + + /// 冻结率下 [start, end] 复利重放利息(notional 线性,故以 closePrincipal 直接重放即可)。 + private static decimal AccrueFrom(DateTime start, DateTime end, FundingLegRate frozenRate, AccrualPolicy policy, decimal notional, AccrualBoundary boundary) + { + if (end <= start) return 0m; + var segs = BuildFrozenSegments(start, end, policy.ResetPeriodDays, start, frozenRate.AllInRate); + return CompoundInterestAccrual.AccruePeriod( + notional: notional, segmentRates: segs, startDate: start, endDate: end, + boundary: boundary, annualDays: policy.AnnualDays, isAnnualized: policy.IsAnnualized, + resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m, + finalBasis: out _, carryInInterest: 0m).Accrued; + } + + /// 最近重置日(≤ date)。重置日判定与 SwapDealService.IsResetDay 同公式,避免跨类耦合。 + private static DateTime LastResetBefore(DateTime date, DateTime anchor, int period) + { + var days = (date - anchor).Days; + if (days <= 0) return anchor; + var offset = days % period; + return anchor.AddDays(days - offset); + } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 98cde2db..58004c90 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -10,6 +10,7 @@ using YLErp.Modules.EodModule; using YLErp.Modules.SwapModule.Accrual; using YLErp.Modules.SwapModule.FundingLegs; using YLErp.Modules.SwapModule.Margin; +using YLErp.Modules.SwapModule.Penalty; using YLErp.Modules.SwapModule.ReturnLegs; using YLErp.Modules.TradeModule; using YLErp.Modules.TradeModule.DealModule; @@ -619,10 +620,31 @@ namespace YLErp.Modules.SwapModule /// 语义契约见 InterestCalcRequest.IntradayUnwind 工厂注释;计息走 CalcUnwindInterest 全区间重放。 /// public List GetIntradayUnwindInterests(InterestCalcRequest req) - => GetInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions, + { + var interests = GetInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions, req.PosiNotionalValue, req.ClosePosiNotionalValue, req.ClosePercent, req.EventType, req.TdClose, req.OrginPv, req.Add, settment: false, req.NewCalcLast, req.CloseList); + // EQD-6977 罚息:在 GetInterests 返回后追加(避开其 closeList 去重块),仅手动平仓路径(isPenaltyInterest)触发。 + if (req.IsPenaltyInterest) + { + var fundingPositions = req.Positions.Where(p => !MarginModes.Contains(p.InterestMode)).ToList(); + var annualDays = req.TradeExtend == null ? 365 : req.TradeExtend.ExtendObj.AnnualDays; + var calcLast = req.TradeExtend?.ExtendObj.CalcLast ?? true; + var trace = new AccrualTrace(); + PenaltyInterestAppender.Append( + req.Td, fundingPositions, interests, req.UnwindDate, annualDays, + unwindDaySettled: calcLast || req.NewCalcLast, + maturityCalcLast: calcLast, + req.PosiNotionalValue, req.ClosePosiNotionalValue, req.ClosePercent, + getSpread: p => GetFixedRate(p, req.UnwindDate), + getPreEodFloatRate: p => req.EodPositions.FirstOrDefault(x => x.PositionId == p.id)?.FloatRate, + tryGetFixing: (d, code) => IndexFixer.TryGetFixing(d, code, out decimal r) ? (decimal?)r : null, + trace: trace); + SwapCalcTrace.Write(trace); // 与既有 4 处 SwapCalcTrace.Write 同款常驻落盘 + } + return interests; + } public List GetInterests( trade td,