feat(swap): EQD-6977 复利纯函数支持 carryInInterest——窗口前已结利息随首个重置日资本化并持续留在基数(与全窗口重放轨迹严格一致);默认0零行为变化,含数学不变量测试

This commit is contained in:
hjhan
2026-08-20 11:43:27 +08:00
parent 952295a78a
commit d1985231ee
2 changed files with 121 additions and 2 deletions
@@ -0,0 +1,107 @@
using Newtonsoft.Json;
using YLErp;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Accrual;
namespace UnitTestProject.Modules.SwapModule.Accrual
{
/// <summary>
/// EQD-6977 carryInInterest 契约测试:
/// 1) 默认 0 与旧逐日循环逐位一致(加参零行为变化的安全证明);
/// 2) carry-in 仅在【首个重置日】并入计息基数(非窗口首日起息)——
/// 与"持有至到期"全期轨迹对齐的数学不变量:增量 = carryIn × 后续段日利率 × 后续段天数。
/// </summary>
[TestClass]
public class CompoundCarryInTest
{
private const decimal Notional = 100_000_000m;
private const decimal Spread = 0.0025m;
private const int AnnualDays = 365;
private static readonly DateTime StartDate = new(2026, 4, 21);
private static readonly DateTime EndDate = new(2026, 5, 11); // 21天 = 3×7,末日是重置日
private static swap_position CreatePosition()
{
return new swap_position
{
id = 1001, SwapTradeId = 1, PosiDirection = 0,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = Spread,
InterestPrincipalFix = Notional,
PosiStartDate = StartDate, PosiMatuirityDate = StartDate.AddYears(1),
IsInitial = true, Invalid = false,
InterestType = (int)InterestTypeEnum.,
IsAnnualized = true, interest_rest_days = 7, interest_rule = 0,
FloatRateUnderlyingCode = null,
InterestSwapInterval = "[]"
};
}
private static List<(DateTime, decimal)> Segments()
=> new()
{
(StartDate, Spread),
(StartDate.AddDays(7), Spread),
(StartDate.AddDays(14), Spread),
};
private sealed class StubSvc : SwapDealService
{
public StubSvc() : base(new OptUserInfo(0, nameof(CompoundCarryInTest), OptUserFrom.UnitTest)) { }
}
[TestMethod]
public void carryIn_默认省略_与旧逐日循环一致()
{
var position = CreatePosition();
var flowEvent = new swap_flow_event { InterestRate = Spread };
decimal oldI = 0, oldTd = 0;
new StubSvc().CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent,
AnnualDays, 0m, 1m, true, false, ref oldI, ref oldTd);
// 省略 carryInInterest(默认 0
var r1 = CompoundInterestAccrual.AccruePeriod(
notional: Notional, segmentRates: Segments(),
startDate: StartDate, endDate: EndDate,
boundary: AccrualBoundary.StartOnly, annualDays: AnnualDays, isAnnualized: true,
resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m,
finalBasis: out _);
// 显式传 0 与省略等价
var r2 = CompoundInterestAccrual.AccruePeriod(
notional: Notional, segmentRates: Segments(),
startDate: StartDate, endDate: EndDate,
boundary: AccrualBoundary.StartOnly, annualDays: AnnualDays, isAnnualized: true,
resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m,
finalBasis: out _, trace: null, carryInInterest: 0m);
Assert.AreEqual((double)oldI, (double)r1.Accrued, 0.0000001, "省略 carryIn 与旧实现一致");
Assert.AreEqual((double)r1.Accrued, (double)r2.Accrued, 0.0000001, "省略与显式0一致");
}
[TestMethod]
public void carryIn_仅在首重置日起息_增量等于后续两段复利()
{
const decimal carryIn = 1_000_000m;
decimal Accrued(decimal c)
=> CompoundInterestAccrual.AccruePeriod(
notional: Notional, segmentRates: Segments(),
startDate: StartDate, endDate: EndDate,
boundary: AccrualBoundary.Both, annualDays: AnnualDays, isAnnualized: true,
resetCarryInterest: 0m, realizedInterest: 0m, unwindFraction: 1m,
finalBasis: out _, trace: null, carryInInterest: c).Accrued;
var delta = Accrued(carryIn) - Accrued(0m);
// Both 边界下三段各 7 天。carryIn 于 4/28(首个重置日)并入基数:
// 首段 [4/21,4/28] 不含 carryIn;其后两段 carryIn 自身起息且其首段利息再复利。
// 精确增量 = c×d + (c + c×d)×d = c×(2d + d²) = c×((1+d)² 1)d = 7天利率因子。
var d = Spread * 7m / AnnualDays;
var expected = carryIn * (2m * d + d * d);
Assert.AreEqual((double)expected, (double)delta, 0.001,
"carryIn 增量 = 首个重置日起息的两段复利,首段不含 carryIn");
}
}
}
@@ -61,6 +61,11 @@ public static class CompoundInterestAccrual
/// 复利多日计息(替换 CalcDailyCompoundInterest 的纯数学部分)。
/// 从 startDate 到 endDate 全程重放,每个重置日把累计利息并入本金。
/// </summary>
/// <param name="carryInInterest">
/// 窗口前已计未结转利息(EQD-6977 罚息承接):在【首个重置日】并入计息基数——
/// 与"持有至到期"全期轨迹严格对齐(恒等式:全期复利 = 平仓日已结利息 + 罚息窗口利息)。
/// 默认 0 时与旧行为逐位一致。与 resetCarryInterest 互斥使用(后者是全平重放的末段存量替代)。
/// </param>
public static InterestResult AccruePeriod(
decimal notional,
IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates,
@@ -73,10 +78,14 @@ public static class CompoundInterestAccrual
decimal realizedInterest,
decimal unwindFraction,
out decimal finalBasis,
AccrualTrace? trace = null)
AccrualTrace? trace = null,
decimal carryInInterest = 0m)
{
decimal accrualBasis = notional;
decimal accrued = 0m;
// carryInInterest 是"窗口前已计未结转利息":先并入 accrued,随首个重置日的
// basis = notional + accrued 一并资本化,并在其后每个重置日持续留在基数里
//(与全窗口重放时 accrued 含全部历史利息的轨迹严格一致);最终报告时扣除。
decimal accrued = carryInInterest;
trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized);
@@ -116,6 +125,9 @@ public static class CompoundInterestAccrual
finalBasis = accrualBasis;
// 报告口径只含窗口内增量(carryIn 是窗口前已结利息,由正常平仓流单独结算)
accrued -= carryInInterest;
if (realizedInterest != 0m)
trace?.Unwind(endDate, unwindFraction, realizedInterest * unwindFraction, accrued - realizedInterest * unwindFraction);
accrued -= realizedInterest * unwindFraction;