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

214 lines
11 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;
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 平仓罚息计算器契约测试。
///
/// 金标准恒等式(需求核心语义"利息端盈亏等同于持有至到期"):
/// 全期利息 = 平仓日已结利息 + 罚息窗口利息
/// 历史口径: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 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()
{
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 swap_flow_event CalcCompoundPenalty(
swap_position p, decimal closePrincipal, bool settled, decimal capitalized, decimal carryIn)
=> SwapPenaltyInterestCalculator.CalcPenalty(
CreateTrade(), p, closePrincipal: 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.平仓, valueDate: UnwindDate);
[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 e = 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}");
}
[TestMethod]
public void 金标准恒等式_复利_不算尾平仓日()
{
// 不算尾:正常结算未计 8/25 → 罚息含 8/25IncludeStart=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 e = CalcCompoundPenalty(p, Notional, settled: false, capitalized, carryIn);
var full = CompoundAccruedTo(MaturityDate, AccrualBoundary.Both);
Assert.AreEqual((double)full, (double)(elapsed + e.InterestAmount), 0.0001,
"不算尾时罚息窗口须补回平仓日,恒等式仍成立");
}
[TestMethod]
public void 单利固定腿_剩余期限利息等于公式()
{
// 需求 2.2.1:剩余利息 = 固定 × 名义本金 × 剩余天数 / 计息基准
// 算尾平仓日 + 到期算尾:窗口 (8/25, 8/31] = 6 天
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.平仓, valueDate: UnwindDate);
var expected = Rate * Notional * 6m / AnnualDays;
Assert.AreEqual((double)expected, (double)e.InterestAmount, 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)[]
{
(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 e = SwapPenaltyInterestCalculator.CalcPenalty(
td, p, closePrincipal: 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.平仓, valueDate: UnwindDate);
var expected = Rate * Notional * days / AnnualDays;
Assert.AreEqual((double)expected, (double)e.InterestAmount, 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.InterestAmount * 0.3m), (double)partial.InterestAmount, 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.平仓, valueDate: UnwindDate);
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,
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.平仓, valueDate: MaturityDate);
Assert.AreEqual(0m, e.InterestAmount, "平仓日=到期日无剩余期限,罚息为 0(承接量不产生利息)");
}
}
}