212 lines
14 KiB
C#
212 lines
14 KiB
C#
using YLErp.Modules.SwapModule.Accrual;
|
||
using YLErp.Modules.SwapModule.Penalty;
|
||
|
||
namespace UnitTestProject.Modules.SwapModule.Penalty
|
||
{
|
||
/// <summary>
|
||
/// EQD-6977 罚息边界矩阵测试(全部断言金标准恒等式:全期 = 实结 + 罚息)。
|
||
///
|
||
/// 覆盖易错边界:
|
||
/// ① 平仓日恰为重置日(算尾/不算尾)——重置日快照基数还是上一段的,①须取 InterestIncomeSum;
|
||
/// ② 重置日前一日平仓(② 几乎整段、窗口首段 0 天);
|
||
/// ③ 到期日恰为重置日(末段 [到期,到期] 1 天);
|
||
/// ④ 锚点偏离(td.StartDate=7/31 但腿 PosiStartDate=8/3 的延期/存续腿——重置网格整体不同);
|
||
/// ⑤ 起息日当天平仓(无 preEod)。
|
||
///
|
||
/// 一致性前提(与现实世界对齐):冻结利率 = 当前重置区间(含 unwind-1 的区间)的在役利率,
|
||
/// 即"历史末段利率 = 冻结利率";历史各段定盘不同(体现真实 FR007 利率历史)。
|
||
/// </summary>
|
||
[TestClass]
|
||
public class PenaltyBoundaryMatrixTest
|
||
{
|
||
private const decimal Notional = 100_000_000m;
|
||
private const int AnnualDays = 365;
|
||
private static readonly decimal[] Hist = { 0.0310m, 0.0420m, 0.0530m, 0.0225m }; // 7/31 / 8/7 / 8/14 / 8/21 段
|
||
private static readonly decimal Frozen = Hist[^1]; // 冻结 = 当前区间在役利率 = 历史末段
|
||
|
||
/// <summary>指定重置网格上的复利重放 [gridStart, end];超出所给历史段后沿用冻结利率。</summary>
|
||
private static decimal AccrueOnGrid(DateTime gridStart, DateTime end, AccrualBoundary boundary,
|
||
decimal[] histRates, decimal notional = Notional, int period = 7)
|
||
{
|
||
var frozen = histRates[^1];
|
||
var segs = new List<(DateTime, decimal)>();
|
||
var i = 0;
|
||
for (var d = gridStart; d <= end; d = d.AddDays(period))
|
||
segs.Add((d, i < histRates.Length ? histRates[i++] : frozen));
|
||
return CompoundInterestAccrual.AccruePeriod(
|
||
notional: notional, segmentRates: segs,
|
||
startDate: gridStart, endDate: end,
|
||
boundary: boundary, annualDays: AnnualDays, isAnnualized: true,
|
||
resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m,
|
||
finalBasis: out _).Accrued;
|
||
}
|
||
|
||
private static trade CreateTrade(DateTime startDate, DateTime maturity)
|
||
=> new()
|
||
{
|
||
id = 1, TradeNumber = "UT-BOUNDARY", ClientId = 999998,
|
||
TradeType = "收益互换", TradeDate = startDate, StartDate = startDate,
|
||
ExerciseDate = maturity, TradeStatus = "确认成交", ValidState = "Valid"
|
||
};
|
||
|
||
private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread, int periodDays = 7)
|
||
=> new()
|
||
{
|
||
id = 1001, SwapTradeId = 1, PosiDirection = 0, InterestDirection = 1,
|
||
InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = spread,
|
||
InterestPrincipalFix = Notional, PosiStartDate = posiStart, PosiMatuirityDate = maturity,
|
||
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利,
|
||
IsAnnualized = true, interest_rest_days = periodDays, interest_rule = 0,
|
||
FloatRateUnderlyingCode = null, InterestSwapInterval = "[]"
|
||
};
|
||
|
||
/// <summary>日终快照:TdInterestPrincipal=当日实际滚动基数、InterestIncomeSum=截至当日待实现利息。</summary>
|
||
private static eod_swap_position Snap(DateTime valueDate, decimal rollingBasis, decimal incomeSum)
|
||
=> new() { id = 9, PositionId = 1001, ValueDate = valueDate,
|
||
TdInterestPrincipal = rollingBasis, InterestIncomeSum = incomeSum };
|
||
|
||
private static decimal RunFee(trade td, swap_position p, decimal settledAmount,
|
||
eod_swap_position? preEod, DateTime unwind, bool settled, decimal spread,
|
||
decimal interestPrincipal = 0m, bool maturityCalcLast = true)
|
||
{
|
||
var e = new swap_flow_event
|
||
{
|
||
PositionId = p.id, InterestAmount = settledAmount, InterestFee = 0m,
|
||
InterestDirection = 1, InterestClosePnL = settledAmount,
|
||
InterestPrincipal = interestPrincipal // 复利主路径下=重放末次并本金后基数(=被平份额本金+①)
|
||
};
|
||
PenaltyInterestFeeMerger.Merge(
|
||
td, new List<swap_position> { p }, new List<swap_flow_event> { e },
|
||
unwind, AnnualDays, settled, maturityCalcLast: maturityCalcLast,
|
||
posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m,
|
||
getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread);
|
||
return e.InterestFee;
|
||
}
|
||
|
||
[TestMethod]
|
||
public void 平仓日恰为重置日_算尾_恒等式成立()
|
||
{
|
||
var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 21); var maturity = new DateTime(2026, 8, 31);
|
||
var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, Hist); // [7/31..8/21](末日=重置日,1 天)
|
||
var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, Hist); // 8/14 起段基数
|
||
var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, Hist); // 8/20 待实现
|
||
|
||
// 前提自检:重置日快照基数(8/14段)≠今日应并入额(8/20待实现),旧公式(basis−P)必错——用例有鉴别力
|
||
Assert.AreNotEqual((double)basisThru813, (double)incomeSum, 1000d, "快照基数与重置日应并入额应显著不同");
|
||
|
||
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed,
|
||
Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: true, spread: Frozen);
|
||
|
||
var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist);
|
||
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
|
||
"重置日当天平仓(算尾):① 须取 InterestIncomeSum,全期=实结+罚息");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void 平仓日恰为重置日_不算尾_恒等式成立()
|
||
{
|
||
var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 21); var maturity = new DateTime(2026, 8, 31);
|
||
var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, Hist); // [7/31..8/20]
|
||
var incomeSum = elapsed; // 不算尾时实结=8/20待实现
|
||
var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, Hist);
|
||
|
||
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed,
|
||
Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: false, spread: Frozen);
|
||
|
||
var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist);
|
||
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
|
||
"重置日当天平仓(不算尾):②=0,罚息含平仓日,全期=实结+罚息");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void 重置日前一日平仓_段内几乎整段承接_恒等式成立()
|
||
{
|
||
var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 27); var maturity = new DateTime(2026, 8, 31);
|
||
var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, Hist); // [7/31..8/27],段内已计 8/21..8/27
|
||
var basisThru820 = AccrueOnGrid(start, new DateTime(2026, 8, 20), AccrualBoundary.Both, Hist); // 8/21 起段基数
|
||
var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, Hist);
|
||
|
||
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, Frozen), elapsed,
|
||
Snap(unwind.AddDays(-1), Notional + basisThru820, incomeSum), unwind, settled: true, spread: Frozen);
|
||
|
||
var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, Hist);
|
||
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
|
||
"重置日前一日平仓:窗口首段 0 天、② 于 8/28 整段并入,全期=实结+罚息");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void 到期日恰为重置日_末段一天_恒等式成立()
|
||
{
|
||
// 8/18 平仓:当前区间为 8/14 段(r3) → 冻结利率=r3=历史末段;到期 9/4 恰为重置日(末段 [9/4,9/4] 1 天)
|
||
var start = new DateTime(2026, 7, 31); var unwind = new DateTime(2026, 8, 18); var maturity = new DateTime(2026, 9, 4);
|
||
var hist = new decimal[] { 0.0310m, 0.0420m, 0.0530m }; // 7/31 / 8/7 / 8/14(=冻结 5.3%)
|
||
var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.Both, hist);
|
||
var basisThru813 = AccrueOnGrid(start, new DateTime(2026, 8, 13), AccrualBoundary.Both, hist);
|
||
var incomeSum = AccrueOnGrid(start, unwind.AddDays(-1), AccrualBoundary.Both, hist);
|
||
|
||
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1]), elapsed,
|
||
Snap(unwind.AddDays(-1), Notional + basisThru813, incomeSum), unwind, settled: true, spread: hist[^1]);
|
||
|
||
var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, hist);
|
||
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
|
||
"到期日=重置日:末段 [9/4,9/4] 1 天收尾,全期=实结+罚息");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void 锚点偏离_延期腿按腿起息日网格_恒等式成立()
|
||
{
|
||
// 交易起始 7/31,但腿 PosiStartDate=8/3(延期/存续腿)→ 真实重置网格 8/10/8/17/8/24/8/31
|
||
var tradeStart = new DateTime(2026, 7, 31); var posiStart = new DateTime(2026, 8, 3);
|
||
var unwind = new DateTime(2026, 8, 19); var maturity = new DateTime(2026, 9, 3);
|
||
var hist = new decimal[] { 0.0300m, 0.0400m, 0.0225m }; // 8/3 / 8/10 / 8/17(=冻结) 三段历史
|
||
|
||
var elapsed = AccrueOnGrid(posiStart, unwind, AccrualBoundary.Both, hist);
|
||
var basisThru816 = AccrueOnGrid(posiStart, new DateTime(2026, 8, 16), AccrualBoundary.Both, hist); // 8/17 起段基数
|
||
var incomeSum = AccrueOnGrid(posiStart, unwind.AddDays(-1), AccrualBoundary.Both, hist);
|
||
|
||
var fee = RunFee(CreateTrade(tradeStart, maturity), CompoundLeg(posiStart, maturity, hist[^1]), elapsed,
|
||
Snap(unwind.AddDays(-1), Notional + basisThru816, incomeSum), unwind, settled: true, spread: hist[^1]);
|
||
|
||
var full = AccrueOnGrid(posiStart, maturity, AccrualBoundary.Both, hist);
|
||
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
|
||
"锚点偏离:罚息分段/重置日判定必须用 position.PosiStartDate 网格(误用 td.StartDate 网格必挂)");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void 无preEod且此前已有重置_经事件基数兜底_恒等式精确成立()
|
||
{
|
||
// UAT 实测场景(tradeId=2447):环境无日终快照、起息后已发生 8/19 重置并本。
|
||
// 兜底① = normalEvent.InterestPrincipal − 本金(复利重放末次并本金后基数);
|
||
// 修复前 ①=0 少算 ≈3.17 元(并入额×冻结利率×段尾天数),本用例钉死兜底路径的精确性。
|
||
var start = new DateTime(2026, 8, 5); var unwind = new DateTime(2026, 8, 20); var maturity = new DateTime(2026, 9, 30);
|
||
var hist = new decimal[] { 0.0216m, 0.0144m }; // 8/5 段 2.16% / 8/19 段 1.44%(=冻结),14 天重置
|
||
var elapsed = AccrueOnGrid(start, unwind, AccrualBoundary.StartOnly, hist, period: 14); // 已结 [8/5..8/19]
|
||
var replayFinalBasis = Notional + AccrueOnGrid(start, new DateTime(2026, 8, 18), AccrualBoundary.Both, hist, period: 14); // 8/19 重置并本后基数
|
||
|
||
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1], periodDays: 14), elapsed,
|
||
preEod: null, unwind: unwind, settled: false, spread: hist[^1],
|
||
interestPrincipal: replayFinalBasis, maturityCalcLast: false); // 不算尾合约、14天重置(对应 UAT tradeId=2447 口径)
|
||
|
||
var full = AccrueOnGrid(start, maturity, AccrualBoundary.StartOnly, hist, period: 14);
|
||
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
|
||
"无preEod+已有重置:兜底取事件基数后 ① 精确,全期=实结+罚息(修复前差≈3.17元)");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void 起息日当天平仓_无preEod_恒等式成立()
|
||
{
|
||
// 首日平仓:当前区间=首段(r1),无 preEod 时取价委托返回首段定盘 → 冻结利率=r1,全程恒率
|
||
var start = new DateTime(2026, 7, 31); var maturity = new DateTime(2026, 8, 31);
|
||
var hist = new decimal[] { 0.0310m };
|
||
var elapsed = AccrueOnGrid(start, start, AccrualBoundary.Both, hist); // 首日 1 天
|
||
|
||
var fee = RunFee(CreateTrade(start, maturity), CompoundLeg(start, maturity, hist[^1]), elapsed,
|
||
preEod: null, unwind: start, settled: true, spread: hist[^1]);
|
||
|
||
var full = AccrueOnGrid(start, maturity, AccrualBoundary.Both, hist);
|
||
Assert.AreEqual((double)full, (double)(elapsed + fee), 0.01,
|
||
"起息日当天平仓:①=0、②=首日利息于 8/7 并入,全期=实结+罚息");
|
||
}
|
||
}
|
||
}
|