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)
{