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

209 lines
12 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,
AccrualTrace? trace = 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,
trace: trace);
}
/// <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 preEod复利段中兜底为零且账龄超重置周期_留退化告警trace()
{
// 场景:无日终快照 + 复利 + 段中平仓,事件 InterestPrincipal 仍是种子值(=平仓本金)→兜底已并复利本金=0。
// 账龄 25 天 ≥ 7 天重置周期:复利每周期并本理应>0,已并复利本金=0 属退化——
// 典型成因=interestWindowEmpty(当日已结息)早退未重放覆盖种子、或日终归档缺失。
var e = NormalEvent(settledAmount: 50_000m);
e.InterestPrincipal = Notional; // GetInterests 种子值:interestWindowEmpty 早退路径不会用重放基数覆盖它
var trace = new AccrualTrace();
RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null, trace: trace);
StringAssert.Contains(trace.ToString(), "无preEod兜底已并复利本金=0",
"已并复利本金=0 且账龄超周期必须留告警,供事后核对日终归档/计息窗口根因");
}
[TestMethod]
public void preEod兜底为正_不留退化告警()
{
var e = NormalEvent(settledAmount: 50_000m);
e.InterestPrincipal = Notional + 100_000m; // 重放末次并本金后基数 → 已并复利本金=100000 正常路径
var trace = new AccrualTrace();
RunMerge(Leg(InterestTypeEnum.复利), e, preEod: null, trace: trace);
Assert.IsFalse(trace.ToString().Contains("兜底已并复利本金=0"), "已并复利本金>0 是正常兜底路径,不得告警");
}
[TestMethod]
public void preEod真首日兜底为零_不留退化告警()
{
var p = Leg(InterestTypeEnum.复利);
p.PosiStartDate = UnwindDate; // 起息日当天平仓:账龄 0 < 重置周期,已并复利本金=0 是设计内约定(类头注)
var e = NormalEvent(settledAmount: 50_000m);
e.InterestPrincipal = Notional;
var trace = new AccrualTrace();
RunMerge(p, e, preEod: null, trace: trace);
Assert.IsFalse(trace.ToString().Contains("兜底已并复利本金=0"), "真首日 已并复利本金=0 合法,不得告警");
}
[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, "正常平仓不受影响");
}
}
}