fix(swap): EQD-6977 罚息精确续接落地——承接量改用实际计息状态(preEod基数+事件实结,弃冻结重放)、锚点统一PosiStartDate、罚息并入既有利息流InterestFee(去独立事件/去is_penalty_interest列及DDL/回退closeList与EOD两处守护)、接缝加eventType==平仓守卫、trace逐腿全程落盘、新增多区间不同定盘恒等式测试(旧冻结重放实现必挂)
This commit is contained in:
@@ -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 测试(无 DB:spread/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 测试(无 DB:spread/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&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_event(EventReason="罚息"),下游结算/报表无差别消费。
|
||||
/// 复利承接(平仓日落在重置段中间时与全期轨迹逐日对齐的两个量,**必须来自实际计息状态**,
|
||||
/// 由调用方 PenaltyInterestFeeMerger 从 preEod 快照与正常平仓流实结金额推导——严禁冻结利率重放推导,
|
||||
/// FR007 有真实利率历史时重放值必偏):
|
||||
/// - capitalizedInterest = 已并入最近重置日的累计利息 → 窗口首日起即加入计息基数;
|
||||
/// - carryInInterest = 最近重置日之后已计至平仓日的利息 → 首个窗口重置日并入并持续留在基数。
|
||||
/// 二者之和 = 被平部分的平仓日实结利息(正常平仓流 InterestAmount)。
|
||||
///
|
||||
/// 产物为金额,由接缝并入既有利息事件的 InterestFee(其他费用含罚息);不产生独立罚息事件。
|
||||
/// 仅融资腿;保证金腿(MarginModes)与浮动端 P&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,保证轨迹严格一致。
|
||||
/// 单利(无重置)时窗口无 carry:capitalized=全段利息、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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user