From ffffe842c61682c3277814af0b9c308d051129ec Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 20 Aug 2026 15:11:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(swap):=20EQD-6977=20=E7=BD=9A=E6=81=AF?= =?UTF-8?q?=E7=B2=BE=E7=A1=AE=E7=BB=AD=E6=8E=A5=E8=90=BD=E5=9C=B0=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=89=BF=E6=8E=A5=E9=87=8F=E6=94=B9=E7=94=A8=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E8=AE=A1=E6=81=AF=E7=8A=B6=E6=80=81(preEod=E5=9F=BA?= =?UTF-8?q?=E6=95=B0+=E4=BA=8B=E4=BB=B6=E5=AE=9E=E7=BB=93=EF=BC=8C?= =?UTF-8?q?=E5=BC=83=E5=86=BB=E7=BB=93=E9=87=8D=E6=94=BE)=E3=80=81?= =?UTF-8?q?=E9=94=9A=E7=82=B9=E7=BB=9F=E4=B8=80PosiStartDate=E3=80=81?= =?UTF-8?q?=E7=BD=9A=E6=81=AF=E5=B9=B6=E5=85=A5=E6=97=A2=E6=9C=89=E5=88=A9?= =?UTF-8?q?=E6=81=AF=E6=B5=81InterestFee(=E5=8E=BB=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6/=E5=8E=BBis=5Fpenalty=5Finterest=E5=88=97?= =?UTF-8?q?=E5=8F=8ADDL/=E5=9B=9E=E9=80=80closeList=E4=B8=8EEOD=E4=B8=A4?= =?UTF-8?q?=E5=A4=84=E5=AE=88=E6=8A=A4)=E3=80=81=E6=8E=A5=E7=BC=9D?= =?UTF-8?q?=E5=8A=A0eventType=3D=3D=E5=B9=B3=E4=BB=93=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E3=80=81trace=E9=80=90=E8=85=BF=E5=85=A8=E7=A8=8B=E8=90=BD?= =?UTF-8?q?=E7=9B=98=E3=80=81=E6=96=B0=E5=A2=9E=E5=A4=9A=E5=8C=BA=E9=97=B4?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=E5=AE=9A=E7=9B=98=E6=81=92=E7=AD=89=E5=BC=8F?= =?UTF-8?q?=E6=B5=8B=E8=AF=95(=E6=97=A7=E5=86=BB=E7=BB=93=E9=87=8D?= =?UTF-8?q?=E6=94=BE=E5=AE=9E=E7=8E=B0=E5=BF=85=E6=8C=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../YLErp.Core/DBModels/SwapFlowEvent.cs | 9 - .../DbUpdate/Ver-5.7.0/prod.sql | 12 -- .../Penalty/PenaltyInterestAppenderTest.cs | 112 ------------ .../Penalty/PenaltyInterestFeeMergerTest.cs | 167 ++++++++++++++++++ .../SwapPenaltyInterestCalculatorTest.cs | 87 +++------ .../Penalty/PenaltyInterestAppender.cs | 83 --------- .../Penalty/PenaltyInterestFeeMerger.cs | 128 ++++++++++++++ .../Penalty/SwapPenaltyInterestCalculator.cs | 138 ++++----------- .../Modules/SwapModule/SwapDealService.cs | 19 +- .../SwapModule/SwapEodPositionService.cs | 2 +- 10 files changed, 361 insertions(+), 396 deletions(-) delete mode 100644 Framework/YLErp.Resources/DbUpdate/Ver-5.7.0/prod.sql delete mode 100644 UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestAppenderTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs delete mode 100644 YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestAppender.cs create mode 100644 YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs diff --git a/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs b/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs index 179525cd..b635b672 100644 --- a/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs +++ b/Framework/YLErp.Core/DBModels/SwapFlowEvent.cs @@ -344,15 +344,6 @@ namespace YLErp.DBModels [DataChange] public decimal InterestClosePnL { get; set; } - /// - /// 是否罚息(EQD-6977):提前终止平仓时利息端按持有至到期计息的标志。0=否,1=是。 - /// 存量数据因 DEFAULT 0 自动为否;新增罚息事件由接缝层置 1。 - /// - [DisplayName("是否罚息")] - [DataChange] - [Column("is_penalty_interest")] - public int IsPenaltyInterest { get; set; } - public long? EventId { get; set; } public int? ClientId { get; set; } public decimal? FloatRate { get; set; } diff --git a/Framework/YLErp.Resources/DbUpdate/Ver-5.7.0/prod.sql b/Framework/YLErp.Resources/DbUpdate/Ver-5.7.0/prod.sql deleted file mode 100644 index 24e0a16b..00000000 --- a/Framework/YLErp.Resources/DbUpdate/Ver-5.7.0/prod.sql +++ /dev/null @@ -1,12 +0,0 @@ --- EQD-6977 提前终止平仓罚息:swap_flow_event 增加“是否罚息”标志列 --- 关联评审:outputs/EQD-6977_DDL发布评审.md --- 影响范围:仅新增一列,存量数据经 DEFAULT 0 自动置“否”,符合需求“存量合约默认不罚息”。 --- 发布窗口:建议在低峰/维护窗口执行(见评审文档锁表风险说明)。 -SET FOREIGN_KEY_CHECKS=0; - -ALTER TABLE `swap_flow_event` - ADD COLUMN `is_penalty_interest` tinyint(1) NOT NULL DEFAULT 0 - COMMENT '是否罚息(EQD-6977):提前终止平仓时利息端按持有至到期计息。0=否,1=是' - AFTER `InterestClosePnL`; - -SET FOREIGN_KEY_CHECKS=1; diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestAppenderTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestAppenderTest.cs deleted file mode 100644 index 9cff946b..00000000 --- a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestAppenderTest.cs +++ /dev/null @@ -1,112 +0,0 @@ -using YLErp.Modules.SwapModule.Accrual; -using YLErp.Modules.SwapModule.Penalty; - -namespace UnitTestProject.Modules.SwapModule.Penalty -{ - /// - /// EQD-6977 罚息接缝 headless 测试(无 DB:spread/preEod/取价 全部以委托注入)。 - /// 锁定:Append 在融资腿上追加 IsPenaltyInterest=1 的同构罚息流;承接恒等式(全期=已结+罚息)。 - /// - [TestClass] - public class PenaltyInterestAppenderTest - { - 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 trade CreateTrade() - => new() - { - id = 1, TradeNumber = "UT-APPEND", ClientId = 999998, - TradeType = "收益互换", StartDate = StartDate, TradeDate = StartDate, - ExerciseDate = MaturityDate, TradeStatus = "确认成交", ValidState = "Valid" - }; - - private static swap_position FixedLeg(InterestTypeEnum interestType) - => new() - { - id = 1001, SwapTradeId = 1, PosiDirection = 0, InterestDirection = 1, - 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 void RunAppend(swap_position p, out List interests, - Func? getSpread = null) - { - getSpread ??= _ => Rate; - interests = new List(); - PenaltyInterestAppender.Append( - CreateTrade(), new List { p }, interests, UnwindDate, AnnualDays, - unwindDaySettled: true, maturityCalcLast: true, - posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, - getSpread: getSpread, - getPreEodFloatRate: _ => null, - tryGetFixing: (d, code) => (decimal?)Rate); - } - - [TestMethod] - public void 固定腿_single利_追加罚息流且标记列() - { - RunAppend(FixedLeg(InterestTypeEnum.单利), out var interests); - - Assert.AreEqual(1, interests.Count, "应恰好追加 1 笔罚息"); - var e = interests[0]; - Assert.AreEqual(1, e.IsPenaltyInterest, "IsPenaltyInterest 应置 1"); - Assert.AreEqual(SwapPenaltyInterestCalculator.PenaltyEventReason, e.EventReason, "事件原因=罚息"); - // 窗口 (8/25, 8/31] = 6 天(算尾平仓日 + 到期算尾) - var expected = Rate * Notional * 6m / AnnualDays; - Assert.AreEqual((double)expected, (double)e.InterestAmount, 0.0001, "单利罚息=利率×本金×天数/基准"); - } - - [TestMethod] - public void 浮动腿_经取价委托解析冻结率并追加() - { - var p = FixedLeg(InterestTypeEnum.单利); - p.FloatRateUnderlyingCode = "FR007"; // 浮动腿:走 tryGetFixing - p.InterestMode = (int)InterestModeEnum.标的期初全价; - // 浮动腿 all-in = 加点利差(spread) + 指数定盘(fixing);零利差时与固定腿同值 - RunAppend(p, out var interests, getSpread: _ => 0m); - - Assert.AreEqual(1, interests.Count); - Assert.AreEqual(1, interests[0].IsPenaltyInterest); - // 取价委托恒返回 Rate → all-in = 0 + Rate,与固定腿同值 - var expected = Rate * Notional * 6m / AnnualDays; - Assert.AreEqual((double)expected, (double)interests[0].InterestAmount, 0.0001, "浮动腿冻结率=取价委托值(零利差)"); - } - - [TestMethod] - public void 复利_承接恒等式_全期等于已结加罚息() - { - var p = FixedLeg(InterestTypeEnum.复利); - RunAppend(p, out var interests); - - Assert.AreEqual(1, interests.Count); - Assert.AreEqual(1, interests[0].IsPenaltyInterest); - - // 与金标准测试同款 CompoundAccruedTo(AccrualBoundary.Both):全期=已结+罚息 恒等式 - var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both); - var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both); - Assert.AreEqual((double)full, (double)(elapsed + interests[0].InterestAmount), 0.0001, - "全期(冻结率重放) 应等于 已结 + 罚息;承接量推导正确"); - } - - /// 常率复利重放 [StartDate, 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; - } - } -} diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs new file mode 100644 index 00000000..2186bfdc --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs @@ -0,0 +1,167 @@ +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Modules.SwapModule.Penalty; + +namespace UnitTestProject.Modules.SwapModule.Penalty +{ + /// + /// EQD-6977 罚息接缝 headless 测试(无 DB:spread/preEod/取价 全部以委托注入)。 + /// 锁定:Merge 把罚息金额并入既有利息事件的 InterestFee(不新增事件、不改 InterestAmount); + /// 承接量取实际计息状态(preEod 基数 + 事件实结金额)——含【多区间不同定盘】恒等式钉死, + /// 该用例在"冻结利率重放推导承接量"的旧实现下必挂(FR007 真实利率历史场景)。 + /// + [TestClass] + public class PenaltyInterestFeeMergerTest + { + 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-MERGE", ClientId = 999998, + TradeType = "收益互换", StartDate = StartDate, TradeDate = StartDate, + ExerciseDate = MaturityDate, TradeStatus = "确认成交", ValidState = "Valid" + }; + + private static swap_position Leg(InterestTypeEnum interestType) + => new() + { + id = 1001, SwapTradeId = 1, PosiDirection = 0, InterestDirection = 1, + 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 = "[]" + }; + + /// 正常平仓利息流(模拟 GetInterests 产出):InterestAmount=实结利息、InterestFee=0。 + private static swap_flow_event NormalEvent(decimal settledAmount) + => new() { PositionId = 1001, InterestAmount = settledAmount, InterestFee = 0m, + InterestDirection = 1, InterestClosePnL = settledAmount }; // 模拟 GetInterests 已算好的 PnL(收取=+1) + + private static eod_swap_position PreEod(decimal rollingBasis, decimal floatRate = 0m) + => new() { id = 9, PositionId = 1001, ValueDate = UnwindDate.AddDays(-1), + TdInterestPrincipal = rollingBasis, FloatRate = floatRate }; + + private static void RunMerge( + swap_position p, swap_flow_event normalEvent, eod_swap_position? preEod, + Func? getSpread = null, Func? tryGetFixing = null) + { + getSpread ??= _ => Rate; + tryGetFixing ??= (d, code) => Rate; + PenaltyInterestFeeMerger.Merge( + CreateTrade(), new List { p }, new List { normalEvent }, + UnwindDate, AnnualDays, + unwindDaySettled: true, maturityCalcLast: true, + posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m, + getSpread: getSpread, + getPreEod: _ => preEod, + tryGetFixing: tryGetFixing); + } + + /// 复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。 + private static decimal CompoundAccruedTo(DateTime endDate, AccrualBoundary boundary, params decimal[] rates) + { + var segs = new List<(DateTime, decimal)>(); + var i = 0; + for (var d = StartDate; d <= endDate; d = d.AddDays(7)) + // 超出所给历史段后沿用最后区间利率——即“未来段冻结为最后区间利率”的语义(勿循环回绕) + segs.Add((d, rates.Length == 1 ? rates[0] : i < rates.Length ? rates[i++] : rates[^1])); + 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; + } + + [TestMethod] + public void 单利固定腿_罚息并入InterestFee_不新增事件() + { + var e = NormalEvent(settledAmount: 50_000m); + RunMerge(Leg(InterestTypeEnum.单利), e, preEod: PreEod(Notional)); + + Assert.AreEqual(0d, (double)(e.InterestFee - Rate * Notional * 6m / AnnualDays), 0.0001, + "罚息=利率×本金×6天/基准(窗口 (8/25, 8/31])"); + Assert.AreEqual(50_000d, (double)e.InterestAmount, 0.0001, "正常实结利息不受影响"); + Assert.AreEqual((double)(50_000m + e.InterestFee), (double)e.InterestClosePnL, 0.0001, "PnL=(实结+罚息)×方向(收取=+1)"); + } + + [TestMethod] + public void 浮动腿_取价委托解析冻结率_并入费用() + { + var p = Leg(InterestTypeEnum.单利); + p.FloatRateUnderlyingCode = "FR007"; + var e = NormalEvent(settledAmount: 50_000m); + // 无 preEod → 走取价委托:all-in = spread(0) + 定盘(Rate) + RunMerge(p, e, preEod: null, getSpread: _ => 0m, tryGetFixing: (d, code) => Rate); + + Assert.AreEqual(0d, (double)(e.InterestFee - Rate * Notional * 6m / AnnualDays), 0.0001, + "浮动腿冻结率=取价委托值(零利差)"); + } + + [TestMethod] + public void 复利常率_承接取实际状态_恒等式全期等于已结加罚息() + { + // 实际计息状态:preEod 滚动基数 = P + 已并入利息(截至 8/20);事件实结 = elapsed([7/31,8/25] Both) + var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both, Rate); + var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both, Rate); + var e = NormalEvent(elapsed); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: PreEod(Notional + capitalized)); + + var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both, Rate); + Assert.AreEqual((double)full, (double)(elapsed + e.InterestFee), 0.0001, + "常率下 全期 = 已结(事件实结) + 罚息(InterestFee)"); + } + + [TestMethod] + public void 复利多区间不同定盘_承接取实际状态_恒等式仍成立() + { + // 真实 FR007 世界:四个历史重置区间定盘各不相同,冻结利率=最后区间(2.25%) + var r1 = 0.0310m; var r2 = 0.0420m; var r3 = 0.0530m; var r4 = Rate; // r4=0.0225 冻结值 + var rates = new[] { r1, r2, r3, r4 }; + + // 实际计息状态(与 GetInterests 重放同源): + var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both, rates); // 已并入 8/21 重置日 + var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both, rates); // 实结(含 8/21..8/25 段内利息) + var e = NormalEvent(elapsed); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: PreEod(Notional + capitalized)); + + // 全期参照:历史段按各自真实定盘、8/28 起的未来段按冻结利率(=r4,恰好同段延续) + var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both, rates); + Assert.AreEqual((double)full, (double)(elapsed + e.InterestFee), 0.01, + "多区间不同定盘下 全期(历史实率+未来冻结) = 实结 + 罚息——承接量必须来自实际状态"); + // 反证旧缺陷:冻结重放推导的承接①(全程 r4)≠ 实际①(分段实率),差额显著 + var frozenReplayCapitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both, Rate); + Assert.AreNotEqual((double)capitalized, (double)frozenReplayCapitalized, 1000d, + "前提自检:分段实率与冻结重放的已并入利息应显著不同(否则用例失去鉴别力)"); + } + + [TestMethod] + public void 复利无preEod_承接退化为实结全额_可计算不崩溃() + { + var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both, Rate); + var e = NormalEvent(elapsed); + RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null); + + Assert.IsTrue(e.InterestFee > 0m, "无 preEod(首日平仓等)仍可计算罚息"); + } + + [TestMethod] + public void 冻结利率解析失败_跳过该腿不阻断() + { + var p = Leg(InterestTypeEnum.单利); + p.FloatRateUnderlyingCode = "FR007"; + var e = NormalEvent(settledAmount: 50_000m); + RunMerge(p, e, preEod: null, getSpread: _ => 0m, tryGetFixing: (d, code) => null); + + Assert.AreEqual(0m, e.InterestFee, "缺价跳过:不加罚息、不抛异常"); + Assert.AreEqual(50_000d, (double)e.InterestAmount, 0.0001, "正常平仓不受影响"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculatorTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculatorTest.cs index a630595f..1696f94d 100644 --- a/UnitTestProject/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculatorTest.cs +++ b/UnitTestProject/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculatorTest.cs @@ -7,10 +7,10 @@ using YLErp.Modules.SwapModule.Penalty; namespace UnitTestProject.Modules.SwapModule.Penalty { /// - /// EQD-6977 平仓罚息计算器契约测试。 + /// EQD-6977 平仓罚息计算器契约测试(返回罚息金额)。 /// - /// 金标准恒等式(需求核心语义"利息端盈亏等同于持有至到期"): - /// 全期利息 = 平仓日已结利息 + 罚息窗口利息 + /// 金标准恒等式(需求核心语义,2026-08-20 裁定的精确续接口径): + /// 全期利息 = 平仓日已结利息 + 罚息金额 /// 历史口径:7/31 起息、8/31 到期、7 天重置(8/7/8/14/8/21/8/28)、8/25 提前终止 /// (平仓日落在 8/21–8/28 重置段中间——复利承接两分量的关键场景)。 /// @@ -25,14 +25,6 @@ namespace UnitTestProject.Modules.SwapModule.Penalty 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() { @@ -65,16 +57,15 @@ namespace UnitTestProject.Modules.SwapModule.Penalty finalBasis: out _).Accrued; } - private static swap_flow_event CalcCompoundPenalty( + private static decimal CalcCompoundPenalty( swap_position p, decimal closePrincipal, bool settled, decimal capitalized, decimal carryIn) - => SwapPenaltyInterestCalculator.CalcPenalty( - CreateTrade(), p, closePrincipal: closePrincipal, + => SwapPenaltyInterestCalculator.CalcPenaltyAmount( + p, 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.平仓); + policy: Policy(p), resetAnchor: StartDate); [TestMethod] public void 金标准恒等式_复利_全期等于已结加罚息() @@ -84,11 +75,11 @@ namespace UnitTestProject.Modules.SwapModule.Penalty var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both); var carryIn = elapsed - capitalized; - var e = CalcCompoundPenalty(p, Notional, settled: true, capitalized, carryIn); + var penalty = 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}"); + Assert.AreEqual((double)full, (double)(elapsed + penalty), 0.0001, + $"全期({full}) 应等于 已结({elapsed}) + 罚息({penalty});承接①={capitalized} ②={carryIn}"); } [TestMethod] @@ -100,10 +91,10 @@ namespace UnitTestProject.Modules.SwapModule.Penalty var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both); var carryIn = elapsed - capitalized; - var e = CalcCompoundPenalty(p, Notional, settled: false, capitalized, carryIn); + var penalty = CalcCompoundPenalty(p, Notional, settled: false, capitalized, carryIn); var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both); - Assert.AreEqual((double)full, (double)(elapsed + e.InterestAmount), 0.0001, + Assert.AreEqual((double)full, (double)(elapsed + penalty), 0.0001, "不算尾时罚息窗口须补回平仓日,恒等式仍成立"); } @@ -112,26 +103,23 @@ namespace UnitTestProject.Modules.SwapModule.Penalty { // 需求 2.2.1:剩余利息 = 固定 × 名义本金 × 剩余天数 / 计息基准 // 算尾平仓日 + 到期算尾:窗口 (8/25, 8/31] = 6 天 - var e = SwapPenaltyInterestCalculator.CalcPenalty( - CreateTrade(), CreatePosition(InterestTypeEnum.单利, SwapDirectionEnum.支付), - closePrincipal: Notional, + var amount = SwapPenaltyInterestCalculator.CalcPenaltyAmount( + CreatePosition(InterestTypeEnum.单利, SwapDirectionEnum.支付), 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.平仓); + resetAnchor: StartDate); var expected = Rate * Notional * 6m / AnnualDays; - Assert.AreEqual((double)expected, (double)e.InterestAmount, 0.0001, "6 天 = 8/26..8/31"); + Assert.AreEqual((double)expected, (double)amount, 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)[] { @@ -142,16 +130,15 @@ namespace UnitTestProject.Modules.SwapModule.Penalty }; foreach (var (settled, calcLast, days) in cases) { - var e = SwapPenaltyInterestCalculator.CalcPenalty( - td, p, closePrincipal: Notional, + var amount = SwapPenaltyInterestCalculator.CalcPenaltyAmount( + p, 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.平仓); + policy: Policy(p), resetAnchor: StartDate); var expected = Rate * Notional * days / AnnualDays; - Assert.AreEqual((double)expected, (double)e.InterestAmount, 0.0001, + Assert.AreEqual((double)expected, (double)amount, 0.0001, $"settled={settled}, calcLast={calcLast} → {days} 天"); } } @@ -168,46 +155,22 @@ namespace UnitTestProject.Modules.SwapModule.Penalty 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, + Assert.AreEqual((double)(full * 0.3m), (double)partial, 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.平仓); - - 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, + var amount = SwapPenaltyInterestCalculator.CalcPenaltyAmount( + CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付), 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.平仓); - Assert.AreEqual(0m, e.InterestAmount, "平仓日=到期日无剩余期限,罚息为 0(承接量不产生利息)"); + resetAnchor: StartDate); + Assert.AreEqual(0m, amount, "平仓日=到期日无剩余期限,罚息为 0(承接量不产生利息)"); } } } diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestAppender.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestAppender.cs deleted file mode 100644 index 6439f85c..00000000 --- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestAppender.cs +++ /dev/null @@ -1,83 +0,0 @@ -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( - unwindDate, resetAnchor, frozenRate, policy, closePrincipal); - - var penalty = SwapPenaltyInterestCalculator.CalcPenalty( - td, position, closePrincipal, unwindDate, maturityDate, - unwindDaySettled, maturityCalcLast, capitalized, carryIn, - frozenRate, policy, resetAnchor, eventType); - 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/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs new file mode 100644 index 00000000..bb963be5 --- /dev/null +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -0,0 +1,128 @@ +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Modules.SwapModule.FundingLegs; +using YLErp.Modules.SwapModule.ReturnLegs; + +namespace YLErp.Modules.SwapModule.Penalty; + +/// +/// EQD-6977 罚息接缝(纯函数,无 DB 依赖):把罚息金额**并入既有平仓利息流的 InterestFee(其他费用含罚息)**。 +/// 不产生独立罚息事件——前端其他费用列/盈亏公式(含 InterestFee)与日终 TdCloseInterestFee 链路天然承接。 +/// +/// 设计:上帝类(SwapDealService.GetIntradayUnwindInterests)仅注入三个外部依赖委托—— +/// getSpread(加点利差)/ getPreEod(上一日终快照行)/ tryGetFixing(定盘取价),本类零 DB 耦合、可 headless 单测。 +/// +/// 复利承接量(精确续接口径的关键)**必须取实际计息状态**,严禁冻结利率重放推导: +/// 承接① capitalized = max(0, preEod.TdInterestPrincipal×份额 − closePrincipal) —— 实际滚动复利基数中已并入部分; +/// 承接② carryIn = 正常平仓流实结 InterestAmount − ① —— 最近重置日后实际已计利息; +/// 无 preEod(首日平仓):①=0、②=实结金额。 +/// 逐腿全程 trace 落盘(SwapCalcTrace),供计算过程分析与错误定位。 +/// +public static class PenaltyInterestFeeMerger +{ + /// + /// 对每条融资腿:解析冻结利率 → 以实际计息状态推导承接量 → 计算罚息 → 并入该腿正常平仓利息事件的 InterestFee。 + /// 取不到冻结利率(浮动腿缺价且无 preEod)时跳过该腿(不阻断正常平仓),留 trace。 + /// + public static void Merge( + trade td, + List fundingPositions, + List interests, + DateTime unwindDate, + int annualDays, + bool unwindDaySettled, + bool maturityCalcLast, + decimal posiNotionalValue, + decimal closePosiNotionalValue, + decimal closePercent, + Func getSpread, + Func getPreEod, + Func tryGetFixing, + AccrualTrace? trace = null) + { + if (td.ExerciseDate == null) + { + trace?.Note("PENALTY|跳过 交易无到期日(ExerciseDate=null)"); + return; + } + var maturityDate = td.ExerciseDate.Value; + + foreach (var position in fundingPositions) + { + // 正常平仓利息流(GetInterests 刚产出)——承接②的事实源与罚息并入目标 + var normalEvent = interests.FirstOrDefault(x => x.PositionId == position.id); + if (normalEvent == null) + { + trace?.Note($"PENALTY|p{position.id} 跳过 无正常平仓利息流(意外:融资腿应有对应事件)"); + continue; + } + + // 复用 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; + } + var share = r.PosiPrincipal > 0m ? Math.Min(1m, closePrincipal / r.PosiPrincipal) : 1m; + var isCompound = position.InterestType == (int)InterestTypeEnum.复利; + + // 冻结利率:前一晚收盘在役利率优先(preEod.FloatRate),无快照再按取价日=unwindDate-1 所在区间取定盘 + FundingLegRate frozenRate; + string rateSource; + var preEod = getPreEod(position); + try + { + frozenRate = PenaltyLegRateResolver.ResolveFrozenRate( + position, getSpread(position), preEod?.FloatRate, unwindDate, + d => tryGetFixing(d, position.FloatRateUnderlyingCode)); + rateSource = preEod != null + ? $"preEod.FloatRate@{preEod.ValueDate:yyyy-MM-dd}" + : "定盘取价(unwindDate-1区间)"; + } + catch (Exception ex) + { + trace?.Note($"PENALTY|p{position.id} 跳过 冻结利率解析失败:{ex.Message}"); + continue; + } + + // 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。 + decimal capitalized = 0m, carryIn = 0m; + if (isCompound) + { + var actualBasisShare = (preEod?.TdInterestPrincipal ?? 0m) * share; + capitalized = Math.Max(0m, actualBasisShare - closePrincipal); + // ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息) + if (capitalized > Math.Max(0m, normalEvent.InterestAmount)) + { + trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:基数推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal)"); + capitalized = Math.Max(0m, normalEvent.InterestAmount); + } + carryIn = normalEvent.InterestAmount - capitalized; + } + + var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound); + // 锚点 = PosiStartDate:与正常计息重放(CalcDailyCompoundInterest 的分段网格)一致,延期腿勿用 td.StartDate + var penalty = SwapPenaltyInterestCalculator.CalcPenaltyAmount( + position, closePrincipal, unwindDate, maturityDate, + unwindDaySettled, maturityCalcLast, + capitalized, carryIn, + frozenRate, policy, position.PosiStartDate, trace); + penalty = InterestMath.Round(penalty, InterestMath.FundingLegPrecision); + + var feeBefore = normalEvent.InterestFee; + normalEvent.InterestFee += penalty; + normalEvent.InterestClosePnL += penalty * DirectionRatio.ReceivePay(position.InterestDirection); + + trace?.Note( + $"PENALTY|p{position.id} 完成 mode={mode} {(isCompound ? "复利" : "单利")} " + + $"窗口=[{unwindDate:yyyy-MM-dd}→{maturityDate:yyyy-MM-dd}] 平仓日已结={unwindDaySettled} 到期算尾={maturityCalcLast} | " + + $"本金 close={closePrincipal:F2} posi={r.PosiPrincipal:F2} share={share:P4} | " + + $"冻结利率={frozenRate.AllInRate:P6} 来源={rateSource} | " + + $"承接①={capitalized:F4} ②={carryIn:F4} 实结={normalEvent.InterestAmount:F4} | " + + $"罚息={penalty:F2} → InterestFee {feeBefore:F2}→{normalEvent.InterestFee:F2} PnL含罚息={normalEvent.InterestClosePnL:F2}"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs b/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs index c8a4bfa3..a2e03d05 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/SwapPenaltyInterestCalculator.cs @@ -1,54 +1,49 @@ -using YLErp.DBModels.Enums; using YLErp.Modules.SwapModule.Accrual; -using YLErp.Modules.SwapModule.ReturnLegs; namespace YLErp.Modules.SwapModule.Penalty; /// -/// EQD-6977 平仓罚息计算器(纯函数)。 +/// EQD-6977 平仓罚息计算器(纯函数)——返回罚息金额。 /// -/// 语义:提前终止时把利息端计息窗口从「平仓日」延长到「原始到期日」,剩余期限按冻结利率计息, -/// 使利息端盈亏等同于持有至到期。金标准恒等式: +/// 口径(2026-08-20 裁定,评审 11.5):**精确续接**。唯一近似 = 未来 FR007 不可得—— +/// 剩余窗口一律用「前一晚收盘在役利率」(preEod.FloatRate,由 PenaltyLegRateResolver 解析); +/// 其余与正常到期计息**丝毫不能差**:每 7 天重置节奏照旧、并本金照旧、单/复利走同一套 +/// Accrual 纯函数。金标准恒等式(验收基准): /// -/// 全期利息 = 平仓日已结利息(正常平仓流) + 罚息窗口利息(本方法) +/// 全期利息 = 平仓日已结利息(正常平仓流) + 罚息金额(本方法) /// -/// 边界规格(需求 2.2 / 评审 8.2,经金标准恒等式测试钉死): +/// 边界规格(经金标准恒等式测试钉死): /// - IncludeStart = !unwindDaySettled:正常结算已计平仓日(算尾)→ 罚息自次日起;不算尾 → 含平仓日; -/// - IncludeEnd = maturityCalcLast:到期日沿用交易自身算尾约定(非本次平仓的 newCalcLast); -/// - 复利承接(平仓日落在重置段中间时与全期轨迹逐日对齐的两个量): -/// capitalizedInterest = 已并入最近重置日的累计利息 → 窗口首日起即加入计息基数 -/// (全期轨迹中当前重置段的滚动基数 = 本金 + 该量,段内每一天都在其上计息); -/// carryInInterest = 最近重置日之后已计至平仓日的利息 → 首个窗口重置日并入并持续留在基数。 -/// 二者之和 = 被平部分的平仓日已结利息(正常平仓流 InterestAmount)。 +/// - IncludeEnd = maturityCalcLast:到期日沿用交易自身算尾约定(非本次平仓的 newCalcLast)。 /// -/// 产物:与正常利息流同构的 swap_flow_event(EventReason="罚息"),下游结算/报表无差别消费。 +/// 复利承接(平仓日落在重置段中间时与全期轨迹逐日对齐的两个量,**必须来自实际计息状态**, +/// 由调用方 PenaltyInterestFeeMerger 从 preEod 快照与正常平仓流实结金额推导——严禁冻结利率重放推导, +/// FR007 有真实利率历史时重放值必偏): +/// - capitalizedInterest = 已并入最近重置日的累计利息 → 窗口首日起即加入计息基数; +/// - carryInInterest = 最近重置日之后已计至平仓日的利息 → 首个窗口重置日并入并持续留在基数。 +/// 二者之和 = 被平部分的平仓日实结利息(正常平仓流 InterestAmount)。 +/// +/// 产物为金额,由接缝并入既有利息事件的 InterestFee(其他费用含罚息);不产生独立罚息事件。 /// 仅融资腿;保证金腿(MarginModes)与浮动端 P&L 不进入本模块。 -/// IsPenaltyInterest 列标记待 DB 迁移(阶段1)落地后由接缝层写入。 /// public static class SwapPenaltyInterestCalculator { - /// 罚息利息流的事件原因(兼作判别字段,存量事件恒为"交易")。 - public const string PenaltyEventReason = "罚息"; - /// - /// 计算罚息窗口 [unwindDate, maturityDate] 的利息流。 + /// 计算罚息窗口 [unwindDate, maturityDate] 的罚息金额。 /// - /// 交易(SwapTradeId/SwapTradeNo/ClientId 载体) /// 被平的融资腿 /// 被平部分计息本金(部分平仓仅算被平份额) - /// 复利承接①:已并入最近重置日的累计利息(被平份额),窗口首日起即入基数;单利传 0 - /// 复利承接②:最近重置日后已计至平仓日的利息(被平份额),首个窗口重置日并入;单利传 0 /// 提前终止日(窗口起点) /// 合约原始到期日(窗口终点,= td.ExerciseDate) /// 正常平仓利息是否已计平仓日(effectiveCalcLast = calcLast || newCalcLast) /// 交易到期日算尾约定(tradeExtend.CalcLast) - /// 冻结利率(PenaltyLegRateResolver.ResolveFrozenRate 产物) + /// 复利承接①:已并入最近重置日的累计利息(被平份额),窗口首日起即入基数;单利传 0 + /// 复利承接②:最近重置日后已计至平仓日的利息(被平份额),首个窗口重置日并入;单利传 0 + /// 冻结利率(PenaltyLegRateResolver.ResolveFrozenRate 产物 = 前一晚收盘在役利率) /// 计息政策(单复利/重置周期/年化天数;Convention 由本方法覆盖) - /// 重置日锚点(对齐 GetFloatRate 的 td.StartDate 口径;锚点二义性注记见其声明) - /// 事件类型(平仓) - /// 计息轨迹(可选,SwapCalcTrace 落盘) - public static swap_flow_event CalcPenalty( - trade td, + /// 重置日锚点 = position.PosiStartDate(与正常计息重放网格一致,勿用 td.StartDate) + /// 计息轨迹(可选,SwapCalcTrace 落盘) + public static decimal CalcPenaltyAmount( swap_position position, decimal closePrincipal, DateTime unwindDate, @@ -60,50 +55,22 @@ public static class SwapPenaltyInterestCalculator FundingLegRate frozenRate, AccrualPolicy policy, DateTime resetAnchor, - int eventType, AccrualTrace? trace = null) { var boundary = AccrualBoundary.Of(includeStart: !unwindDaySettled, includeEnd: maturityCalcLast); var allInRate = frozenRate.AllInRate; - // 按计息方式分派到对应的纯计息路径;二者均产出 (累计利息, 末段计息基数)。 - // 计息数学细节下沉到具名方法,使本方法只表达“分派 + 组装事件”的编排意图,便于阅读与单测。 - var (accrued, finalBasis) = policy.IsCompound + return policy.IsCompound ? AccrueCompound(closePrincipal, capitalizedInterest, carryInInterest, unwindDate, maturityDate, boundary, policy, resetAnchor, allInRate, trace) : AccrueSimple(closePrincipal, unwindDate, maturityDate, boundary, policy, allInRate, trace); - - var rounded = InterestMath.Round(accrued, InterestMath.FundingLegPrecision); - var interest = new swap_flow_event - { - SwapTradeId = td.id, - SwapTradeNo = td.TradeNumber, - EventType = eventType, - EventReason = PenaltyEventReason, - EventDate = unwindDate, - 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; } /// /// 复利罚息计息:即使冻结利率为单值,也必须按重置日分段(并本金发生在分段边界),每段同一冻结利率。 /// notional = 本金 + 已并入最近重置日的利息(capitalizedInterest):全期轨迹中当前重置段的滚动基数, /// 段内每一天都在其上计息——平仓日落在段中间时与全期逐日对齐的关键。carryInInterest 在首个窗口重置日并入。 - /// 返回 (累计利息, 末段计息基数)。 /// - private static (decimal Accrued, decimal FinalBasis) AccrueCompound( + private static decimal AccrueCompound( decimal closePrincipal, decimal capitalizedInterest, decimal carryInInterest, DateTime unwindDate, DateTime maturityDate, AccrualBoundary boundary, AccrualPolicy policy, DateTime resetAnchor, decimal allInRate, AccrualTrace? trace) @@ -120,17 +87,16 @@ public static class SwapPenaltyInterestCalculator resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m, - finalBasis: out var finalBasis, + finalBasis: out _, trace: trace, carryInInterest: carryInInterest); - return (r.Accrued, finalBasis); + return r.Accrued; } /// - /// 单利罚息计息:无并本金语义,冻结利率即单段全程。finalBasis 恒为本金(单利不滚基数)。 - /// 返回 (累计利息, 末段计息基数)。 + /// 单利罚息计息:无并本金语义,冻结利率即单段全程(需求 2.2.1 公式:利率 × 名义本金 × 剩余天数 / 计息基准)。 /// - private static (decimal Accrued, decimal FinalBasis) AccrueSimple( + private static decimal AccrueSimple( decimal closePrincipal, DateTime unwindDate, DateTime maturityDate, AccrualBoundary boundary, AccrualPolicy policy, decimal allInRate, AccrualTrace? trace) { @@ -146,7 +112,7 @@ public static class SwapPenaltyInterestCalculator annualDays: policy.AnnualDays, isAnnualized: policy.IsAnnualized, trace: trace); - return (r.Accrued, closePrincipal); + return r.Accrued; } /// @@ -168,48 +134,4 @@ public static class SwapPenaltyInterestCalculator } return segments; } - - /// - /// 复利承接量推导(冻结率重放,自洽的“冻结率持有至到期”语义,与金标准测试模型一致): - /// capitalizedInterest = 已并入最近重置日的累计利息 → 窗口首日起即入基数; - /// carryInInterest = 最近重置日之后已计至平仓日的利息 → 首个窗口重置日并入。 - /// 二者之和 = 被平部分平仓日已结利息(正常平仓流 InterestAmount 在冻结率下的重放值)。 - /// 重放采用与 AccrueCompound 相同的 BuildFrozenSegments + AccruePeriod,保证轨迹严格一致。 - /// 单利(无重置)时窗口无 carry:capitalized=全段利息、carryIn=0。 - /// - public static (decimal Capitalized, decimal CarryIn) ComputeCarryBreakdown( - 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 99f4cde0..1160d34f 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -479,8 +479,7 @@ namespace YLErp.Modules.SwapModule var closeList = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId && x.UnwindDate == unwindDate && eventTypes.Contains(x.EventType) - && x.DataState == (int)SwapFlowDateStateEnum.完成 - && x.IsPenaltyInterest != 1).ToList(); + && x.DataState == (int)SwapFlowDateStateEnum.完成).ToList(); bool tdClose = closeList.Count > 0; // 显式入口:平仓前剩余本金 + 实际平掉额 + B语义比例,盘中重放(语义见 InterestCalcRequest.IntradayUnwind) interests = GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind( @@ -625,30 +624,32 @@ namespace YLErp.Modules.SwapModule 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) - AppendPenaltyInterests(req, interests); + // EQD-6977 罚息:GetInterests 返回后将罚息金额并入既有利息流的 InterestFee(其他费用含罚息)。 + // 仅手动平仓(isPenaltyInterest)且事件类型为平仓时触发;互换结现路径不带罚息。 + if (req.IsPenaltyInterest && req.EventType == (int)SwapEventTypeEnum.平仓) + MergePenaltyIntoFee(req, interests); return interests; } /// - /// EQD-6977 罚息接缝(委托注入 + 轨迹落盘):在 GetInterests 返回后把同构罚息流追加进列表。 + /// EQD-6977 罚息接缝(委托注入 + 轨迹落盘):在 GetInterests 返回后把罚息金额并入 + /// 各融资腿正常平仓利息事件的 InterestFee(不产生独立罚息事件)。 /// 仅在此处耦合上帝类的利率解析(GetFixedRate / IndexFixer)与轨迹常驻落盘(SwapCalcTrace.Write), /// 其余罚息计息数学全部下沉至 Penalty 模块,保持上帝类最小侵入。 /// - private void AppendPenaltyInterests(InterestCalcRequest req, List interests) + private void MergePenaltyIntoFee(InterestCalcRequest req, List interests) { 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( + PenaltyInterestFeeMerger.Merge( 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, + getPreEod: p => req.EodPositions.FirstOrDefault(x => x.PositionId == p.id), tryGetFixing: (d, code) => IndexFixer.TryGetFixing(d, code, out decimal r) ? (decimal?)r : null, trace: trace); SwapCalcTrace.Write(trace); // 与既有 4 处 SwapCalcTrace.Write 同款常驻落盘 diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 07117e3d..714ba36d 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1352,7 +1352,7 @@ namespace YLErp.Modules.SwapModule // manualSettledInterestAmount:swap_flow_event 实际落库的手工结息,金额已按分处理。 decimal TdInterestAmount = interests.Sum(x => x.TdInterestAmount); decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount); - decimal manualSettledInterestAmount = flowEvents.Where(x => x.IsPenaltyInterest != 1).Sum(x => x.InterestAmount); + decimal manualSettledInterestAmount = flowEvents.Sum(x => x.InterestAmount); decimal autoSettledInterestAmount = 0m; if (autoSwap && interests.Count > 0) {