Files
zszq-trs/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs
T

168 lines
9.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "正常平仓不受影响");
}
}
}