fix(swap): EQD-6977 罚息精确续接落地——承接量改用实际计息状态(preEod基数+事件实结,弃冻结重放)、锚点统一PosiStartDate、罚息并入既有利息流InterestFee(去独立事件/去is_penalty_interest列及DDL/回退closeList与EOD两处守护)、接缝加eventType==平仓守卫、trace逐腿全程落盘、新增多区间不同定盘恒等式测试(旧冻结重放实现必挂)

This commit is contained in:
hjhan
2026-08-20 15:11:33 +08:00
parent 09660fffea
commit ffffe842c6
10 changed files with 361 additions and 396 deletions
@@ -344,15 +344,6 @@ namespace YLErp.DBModels
[DataChange]
public decimal InterestClosePnL { get; set; }
/// <summary>
/// 是否罚息(EQD-6977):提前终止平仓时利息端按持有至到期计息的标志。0=否,1=是。
/// 存量数据因 DEFAULT 0 自动为否;新增罚息事件由接缝层置 1。
/// </summary>
[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; }
@@ -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;
@@ -1,112 +0,0 @@
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.Penalty;
namespace UnitTestProject.Modules.SwapModule.Penalty
{
/// <summary>
/// EQD-6977 罚息接缝 headless 测试(无 DBspread/preEod/取价 全部以委托注入)。
/// 锁定:Append 在融资腿上追加 IsPenaltyInterest=1 的同构罚息流;承接恒等式(全期=已结+罚息)。
/// </summary>
[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<swap_flow_event> interests,
Func<swap_position, decimal>? getSpread = null)
{
getSpread ??= _ => Rate;
interests = new List<swap_flow_event>();
PenaltyInterestAppender.Append(
CreateTrade(), new List<swap_position> { 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,
"全期(冻结率重放) 应等于 已结 + 罚息;承接量推导正确");
}
/// <summary>常率复利重放 [StartDate, endDate],重置段 = 每 7 天(与金标准测试一致)。</summary>
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;
}
}
}
@@ -0,0 +1,167 @@
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.Penalty;
namespace UnitTestProject.Modules.SwapModule.Penalty
{
/// <summary>
/// EQD-6977 罚息接缝 headless 测试(无 DBspread/preEod/取价 全部以委托注入)。
/// 锁定:Merge 把罚息金额并入既有利息事件的 InterestFee(不新增事件、不改 InterestAmount);
/// 承接量取实际计息状态(preEod 基数 + 事件实结金额)——含【多区间不同定盘】恒等式钉死,
/// 该用例在"冻结利率重放推导承接量"的旧实现下必挂(FR007 真实利率历史场景)。
/// </summary>
[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 = "[]"
};
/// <summary>正常平仓利息流(模拟 GetInterests 产出):InterestAmount=实结利息、InterestFee=0。</summary>
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<swap_position, decimal>? getSpread = null, Func<DateTime, string, decimal?>? tryGetFixing = null)
{
getSpread ??= _ => Rate;
tryGetFixing ??= (d, code) => Rate;
PenaltyInterestFeeMerger.Merge(
CreateTrade(), new List<swap_position> { p }, new List<swap_flow_event> { normalEvent },
UnwindDate, AnnualDays,
unwindDaySettled: true, maturityCalcLast: true,
posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m,
getSpread: getSpread,
getPreEod: _ => preEod,
tryGetFixing: tryGetFixing);
}
/// <summary>复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。</summary>
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, "正常平仓不受影响");
}
}
}
@@ -7,10 +7,10 @@ using YLErp.Modules.SwapModule.Penalty;
namespace UnitTestProject.Modules.SwapModule.Penalty
{
/// <summary>
/// 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 重置段中间——复利承接两分量的关键场景)。
/// </summary>
@@ -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(承接量不产生利息)");
}
}
}
@@ -1,83 +0,0 @@
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.FundingLegs;
namespace YLErp.Modules.SwapModule.Penalty;
/// <summary>
/// EQD-6977 罚息接缝(纯函数,不含 DB 依赖):把罚息利息流追加进盘中平仓利息列表。
///
/// 设计:上帝类(SwapDealService.GetIntradayUnwindInterests)仅需把三个外部依赖以委托注入——
/// getSpread(加点利差)/ getPreEodFloatRate(上一日终快照利率)/ tryGetFixing(定盘取价),
/// 本类自身零 DB 耦合、可 headless 单测。罚息事件经 CalcPenalty 产出,置 IsPenaltyInterest=1。
///
/// 仅作用融资腿(调用方已预过滤保证金腿 MarginModes);浮动端 P&amp;L 不进入。
/// 取不到冻结利率(浮动腿缺价且无 preEod)时跳过该腿罚息(不阻断正常平仓),并留 trace。
/// </summary>
public static class PenaltyInterestAppender
{
public static void Append(
trade td,
List<swap_position> fundingPositions,
List<swap_flow_event> interests,
DateTime unwindDate,
int annualDays,
bool unwindDaySettled,
bool maturityCalcLast,
decimal posiNotionalValue,
decimal closePosiNotionalValue,
decimal closePercent,
Func<swap_position, decimal> getSpread,
Func<swap_position, decimal?> getPreEodFloatRate,
Func<DateTime, string, decimal?> 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<DateTime, decimal?> 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}");
}
}
}
@@ -0,0 +1,128 @@
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.FundingLegs;
using YLErp.Modules.SwapModule.ReturnLegs;
namespace YLErp.Modules.SwapModule.Penalty;
/// <summary>
/// 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),供计算过程分析与错误定位。
/// </summary>
public static class PenaltyInterestFeeMerger
{
/// <summary>
/// 对每条融资腿:解析冻结利率 → 以实际计息状态推导承接量 → 计算罚息 → 并入该腿正常平仓利息事件的 InterestFee。
/// 取不到冻结利率(浮动腿缺价且无 preEod)时跳过该腿(不阻断正常平仓),留 trace。
/// </summary>
public static void Merge(
trade td,
List<swap_position> fundingPositions,
List<swap_flow_event> interests,
DateTime unwindDate,
int annualDays,
bool unwindDaySettled,
bool maturityCalcLast,
decimal posiNotionalValue,
decimal closePosiNotionalValue,
decimal closePercent,
Func<swap_position, decimal> getSpread,
Func<swap_position, eod_swap_position?> getPreEod,
Func<DateTime, string, decimal?> 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}");
}
}
}
@@ -1,54 +1,49 @@
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule.Accrual;
using YLErp.Modules.SwapModule.ReturnLegs;
namespace YLErp.Modules.SwapModule.Penalty;
/// <summary>
/// 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_eventEventReason="罚息"),下游结算/报表无差别消费。
/// 复利承接(平仓日落在重置段中间时与全期轨迹逐日对齐的两个量,**必须来自实际计息状态**,
/// 由调用方 PenaltyInterestFeeMerger 从 preEod 快照与正常平仓流实结金额推导——严禁冻结利率重放推导,
/// FR007 有真实利率历史时重放值必偏):
/// - capitalizedInterest = 已并入最近重置日的累计利息 → 窗口首日起即加入计息基数;
/// - carryInInterest = 最近重置日之后已计至平仓日的利息 → 首个窗口重置日并入并持续留在基数。
/// 二者之和 = 被平部分的平仓日实结利息(正常平仓流 InterestAmount)。
///
/// 产物为金额,由接缝并入既有利息事件的 InterestFee(其他费用含罚息);不产生独立罚息事件。
/// 仅融资腿;保证金腿(MarginModes)与浮动端 P&amp;L 不进入本模块。
/// IsPenaltyInterest 列标记待 DB 迁移(阶段1)落地后由接缝层写入。
/// </summary>
public static class SwapPenaltyInterestCalculator
{
/// <summary>罚息利息流的事件原因(兼作判别字段,存量事件恒为"交易")。</summary>
public const string PenaltyEventReason = "罚息";
/// <summary>
/// 计算罚息窗口 [unwindDate, maturityDate] 的利息流
/// 计算罚息窗口 [unwindDate, maturityDate] 的罚息金额
/// </summary>
/// <param name="td">交易(SwapTradeId/SwapTradeNo/ClientId 载体)</param>
/// <param name="position">被平的融资腿</param>
/// <param name="closePrincipal">被平部分计息本金(部分平仓仅算被平份额)</param>
/// <param name="capitalizedInterest">复利承接①:已并入最近重置日的累计利息(被平份额),窗口首日起即入基数;单利传 0</param>
/// <param name="carryInInterest">复利承接②:最近重置日后已计至平仓日的利息(被平份额),首个窗口重置日并入;单利传 0</param>
/// <param name="unwindDate">提前终止日(窗口起点)</param>
/// <param name="maturityDate">合约原始到期日(窗口终点,= td.ExerciseDate</param>
/// <param name="unwindDaySettled">正常平仓利息是否已计平仓日(effectiveCalcLast = calcLast || newCalcLast</param>
/// <param name="maturityCalcLast">交易到期日算尾约定(tradeExtend.CalcLast</param>
/// <param name="frozenRate">冻结利率(PenaltyLegRateResolver.ResolveFrozenRate 产物)</param>
/// <param name="capitalizedInterest">复利承接①:已并入最近重置日的累计利息(被平份额),窗口首日起即入基数;单利传 0</param>
/// <param name="carryInInterest">复利承接②:最近重置日后已计至平仓日的利息(被平份额),首个窗口重置日并入;单利传 0</param>
/// <param name="frozenRate">冻结利率(PenaltyLegRateResolver.ResolveFrozenRate 产物 = 前一晚收盘在役利率)</param>
/// <param name="policy">计息政策(单复利/重置周期/年化天数;Convention 由本方法覆盖)</param>
/// <param name="resetAnchor">重置日锚点(对齐 GetFloatRate 的 td.StartDate 口径;锚点二义性注记见其声明</param>
/// <param name="eventType">事件类型(平仓</param>
/// <param name="trace">计息轨迹(可选,SwapCalcTrace 落盘)</param>
public static swap_flow_event CalcPenalty(
trade td,
/// <param name="resetAnchor">重置日锚点 = position.PosiStartDate(与正常计息重放网格一致,勿用 td.StartDate</param>
/// <param name="trace">计息轨迹(可选,SwapCalcTrace 落盘</param>
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;
}
/// <summary>
/// 复利罚息计息:即使冻结利率为单值,也必须按重置日分段(并本金发生在分段边界),每段同一冻结利率。
/// notional = 本金 + 已并入最近重置日的利息(capitalizedInterest):全期轨迹中当前重置段的滚动基数,
/// 段内每一天都在其上计息——平仓日落在段中间时与全期逐日对齐的关键。carryInInterest 在首个窗口重置日并入。
/// 返回 (累计利息, 末段计息基数)。
/// </summary>
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;
}
/// <summary>
/// 单利罚息计息:无并本金语义,冻结利率即单段全程。finalBasis 恒为本金(单利不滚基数)。
/// 返回 (累计利息, 末段计息基数)。
/// 单利罚息计息:无并本金语义,冻结利率即单段全程(需求 2.2.1 公式:利率 × 名义本金 × 剩余天数 / 计息基准)。
/// </summary>
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;
}
/// <summary>
@@ -168,48 +134,4 @@ public static class SwapPenaltyInterestCalculator
}
return segments;
}
/// <summary>
/// 复利承接量推导(冻结率重放,自洽的“冻结率持有至到期”语义,与金标准测试模型一致):
/// capitalizedInterest = 已并入最近重置日的累计利息 → 窗口首日起即入基数;
/// carryInInterest = 最近重置日之后已计至平仓日的利息 → 首个窗口重置日并入。
/// 二者之和 = 被平部分平仓日已结利息(正常平仓流 InterestAmount 在冻结率下的重放值)。
/// 重放采用与 AccrueCompound 相同的 BuildFrozenSegments + AccruePeriod,保证轨迹严格一致。
/// 单利(无重置)时窗口无 carrycapitalized=全段利息、carryIn=0。
/// </summary>
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);
}
/// <summary>冻结率下 [start, end] 复利重放利息(notional 线性,故以 closePrincipal 直接重放即可)。</summary>
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;
}
/// <summary>最近重置日(≤ date)。重置日判定与 SwapDealService.IsResetDay 同公式,避免跨类耦合。</summary>
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);
}
}
+10 -9
View File
@@ -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;
}
/// <summary>
/// EQD-6977 罚息接缝(委托注入 + 轨迹落盘):在 GetInterests 返回后把同构罚息流追加进列表。
/// EQD-6977 罚息接缝(委托注入 + 轨迹落盘):在 GetInterests 返回后把罚息金额并入
/// 各融资腿正常平仓利息事件的 InterestFee(不产生独立罚息事件)。
/// 仅在此处耦合上帝类的利率解析(GetFixedRate / IndexFixer)与轨迹常驻落盘(SwapCalcTrace.Write),
/// 其余罚息计息数学全部下沉至 Penalty 模块,保持上帝类最小侵入。
/// </summary>
private void AppendPenaltyInterests(InterestCalcRequest req, List<swap_flow_event> interests)
private void MergePenaltyIntoFee(InterestCalcRequest req, List<swap_flow_event> 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 同款常驻落盘
@@ -1352,7 +1352,7 @@ namespace YLErp.Modules.SwapModule
// manualSettledInterestAmountswap_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)
{