177 lines
9.1 KiB
C#
177 lines
9.1 KiB
C#
using YLErp;
|
||
using YLErp.DBModels.Enums;
|
||
using YLErp.Modules.SwapModule;
|
||
using YLErp.Modules.SwapModule.Accrual;
|
||
using YLErp.Modules.SwapModule.Penalty;
|
||
|
||
namespace UnitTestProject.Modules.SwapModule.Penalty
|
||
{
|
||
/// <summary>
|
||
/// 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>
|
||
[TestClass]
|
||
public class SwapPenaltyInterestCalculatorTest
|
||
{
|
||
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 swap_position CreatePosition(InterestTypeEnum interestType, SwapDirectionEnum direction)
|
||
=> new()
|
||
{
|
||
id = 1001, SwapTradeId = 1, PosiDirection = 0,
|
||
InterestDirection = (int)direction,
|
||
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 AccrualPolicy Policy(swap_position p)
|
||
=> AccrualPolicy.BuildEod(p, AnnualDays, p.InterestType == (int)InterestTypeEnum.复利);
|
||
|
||
/// <summary>常率复利重放 [7/31, 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;
|
||
}
|
||
|
||
private static decimal CalcCompoundPenalty(
|
||
swap_position p, decimal closePrincipal, bool settled, decimal capitalized, decimal carryIn)
|
||
=> 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);
|
||
|
||
[TestMethod]
|
||
public void 金标准恒等式_复利_全期等于已结加罚息()
|
||
{
|
||
var p = CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付);
|
||
var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both);
|
||
var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both);
|
||
var carryIn = elapsed - capitalized;
|
||
|
||
var penalty = CalcCompoundPenalty(p, Notional, settled: true, capitalized, carryIn);
|
||
|
||
var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both);
|
||
Assert.AreEqual((double)full, (double)(elapsed + penalty), 0.0001,
|
||
$"全期({full}) 应等于 已结({elapsed}) + 罚息({penalty});承接①={capitalized} ②={carryIn}");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void 金标准恒等式_复利_不算尾平仓日()
|
||
{
|
||
// 不算尾:正常结算未计 8/25 → 罚息含 8/25(IncludeStart=true),承接②少一天
|
||
var p = CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付);
|
||
var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.StartOnly);
|
||
var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both);
|
||
var carryIn = elapsed - capitalized;
|
||
|
||
var penalty = CalcCompoundPenalty(p, Notional, settled: false, capitalized, carryIn);
|
||
|
||
var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both);
|
||
Assert.AreEqual((double)full, (double)(elapsed + penalty), 0.0001,
|
||
"不算尾时罚息窗口须补回平仓日,恒等式仍成立");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void 单利固定腿_剩余期限利息等于公式()
|
||
{
|
||
// 需求 2.2.1:剩余利息 = 固定 × 名义本金 × 剩余天数 / 计息基准
|
||
// 算尾平仓日 + 到期算尾:窗口 (8/25, 8/31] = 6 天
|
||
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);
|
||
|
||
var expected = Rate * Notional * 6m / AnnualDays;
|
||
Assert.AreEqual((double)expected, (double)amount, 0.0001, "6 天 = 8/26..8/31");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void 边界四象限_剩余天数口径正确()
|
||
{
|
||
var p = CreatePosition(InterestTypeEnum.单利, SwapDirectionEnum.支付);
|
||
// 8/26..8/31 共 6 个计息日候选;IncludeStart 加 8/25、IncludeEnd 加 8/31 由约定裁剪
|
||
var cases = new (bool settled, bool calcLast, int days)[]
|
||
{
|
||
(true, true, 6), // (8/25, 8/31] 8/26..8/31
|
||
(true, false, 5), // (8/25, 8/31) 8/26..8/30
|
||
(false, true, 7), // [8/25, 8/31] 8/25..8/31
|
||
(false, false, 6), // [8/25, 8/31) 8/25..8/30
|
||
};
|
||
foreach (var (settled, calcLast, days) in cases)
|
||
{
|
||
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);
|
||
var expected = Rate * Notional * days / AnnualDays;
|
||
Assert.AreEqual((double)expected, (double)amount, 0.0001,
|
||
$"settled={settled}, calcLast={calcLast} → {days} 天");
|
||
}
|
||
}
|
||
|
||
[TestMethod]
|
||
public void 部分平仓_仅被平份额计罚息()
|
||
{
|
||
var p = CreatePosition(InterestTypeEnum.复利, SwapDirectionEnum.支付);
|
||
var elapsed = CompoundAccruedTo(UnwindDate, AccrualBoundary.Both);
|
||
var capitalized = CompoundAccruedTo(LastResetBeforeUnwind.AddDays(-1), AccrualBoundary.Both);
|
||
var carryIn = elapsed - capitalized;
|
||
|
||
// 被平 30%:本金与两承接量同比缩放,罚息应恰为全额的 30%
|
||
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 * 0.3m), (double)partial, 0.0001,
|
||
"被平 30%(本金与承接量同比)罚息应恰为全额的 30%");
|
||
}
|
||
|
||
[TestMethod]
|
||
public void 零剩余期限_金额为零()
|
||
{
|
||
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);
|
||
Assert.AreEqual(0m, amount, "平仓日=到期日无剩余期限,罚息为 0(承接量不产生利息)");
|
||
}
|
||
}
|
||
}
|