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,