feat(accrual): 新增 AccrueCompoundPeriod + 影子测试(3个全过)

第3步迁移: CalcDailyCompoundInterest(盘中复利多日)。

新增纯函数 AccrueCompoundPeriod:
- 从 PosiStartDate 到 endDate 全程重放
- 每个重置日并本金: principal + replayed interest
- 末日(endDate)为重置日且 resetCarryInterest!=0 时用存量替代
- consumedInterest × closePercent 在末尾扣除
- 分段模型替代逐日循环, 段内本金恒定+利率恒定

影子测试(3个,全过):
- 固定利率全平+末日重置日: 旧新一致
- 部分平仓30%+consumedInterest扣除: 旧新一致
- 算头算尾(calcMode=11): 旧新一致

清理: 删除 SwapInterestAccrueCompoundInArrearsTest(半成品残留,
依赖已回退的 SwapInterest 签名)。
修复: SwapInterestAccrueCompoundInArrearsTest 缺 using(已随文件删除)。

验证: 编译0错误, 全量520测试7失败(基线一致)。
4个CalcDaily*方法的新架构纯函数全部就绪, 旧方法暂保留对比。
This commit is contained in:
hjhan
2026-08-12 09:48:49 +08:00
parent 963b5e66af
commit 431ac9b502
2 changed files with 275 additions and 0 deletions
@@ -0,0 +1,187 @@
using Newtonsoft.Json;
using YLErp;
using YLErp.Derivatives.Interest;
using YLErp.Modules.SwapModule;
using YLErp.Modules.SwapModule.Accrual;
namespace UnitTestProject.Modules.SwapModule.Accrual
{
/// <summary>
/// 影子测试:CalcDailyCompoundInterest(旧逐日循环)vs FundingLegAccrual.AccrueCompoundPeriod(新分段纯函数)。
/// </summary>
[TestClass]
public class CompoundPeriodShadowTest
{
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 trade CreateTrade()
{
return new trade
{
id = 1, TradeNumber = "UT-COMPOUND-SHADOW", ClientId = 999998,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = StartDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid",
trade_extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0
})
}
};
}
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 sealed class StubSvc : SwapDealService
{
public StubSvc() : base(new OptUserInfo(0, nameof(CompoundPeriodShadowTest), OptUserFrom.UnitTest)) { }
}
/// <summary>
/// 固定利率(无FR007)算头不算尾,全平。
/// 重置日 4/28, 5/5, 末日 5/11 恰为重置日(21天=3×7)。
/// </summary>
[TestMethod]
public void _固定利率_全平_末日重置日_旧新一致()
{
var position = CreatePosition();
var flowEvent = new swap_flow_event { InterestRate = Spread };
var preEod = new eod_swap_position { id = 0 };
// 旧方法
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent,
AnnualDays, false, 0m, 1m, Notional, true, false,
ref oldI, ref oldTd);
// 新方法:固定利率全段相同,分段点 = PosiStartDate + k×7
// 段: [4/21,4/28), [4/28,5/5), [5/5,5/11] → 注意旧代码 calcLast=false 不算末日
var allInRate = Spread; // 无浮动利率
var segRates = new List<(DateTime, decimal)>
{
(StartDate, allInRate),
(StartDate.AddDays(7), allInRate),
(StartDate.AddDays(14), allInRate),
};
var result = FundingLegAccrual.AccrueCompoundPeriod(
principal: Notional,
segmentRates: segRates,
startDate: StartDate,
endDate: EndDate,
boundary: AccrualBoundary.StartOnly,
annualDays: AnnualDays,
isAnnualized: true);
Console.WriteLine($"旧: I={oldI} Td={oldTd}");
Console.WriteLine($"新: Accrued={result.Accrued} AccruedToday={result.AccruedToday}");
Assert.AreEqual((double)oldI, (double)result.Accrued, 0.01, "InterestAmount 一致");
}
/// <summary>
/// 部分平仓 30% + consumedInterest 扣除。
/// </summary>
[TestMethod]
public void _部分平仓30pct_consumedInterest_旧新一致()
{
var position = CreatePosition();
var flowEvent = new swap_flow_event { InterestRate = Spread };
var preEod = new eod_swap_position { id = 0 };
const decimal consumed = 50_000m;
const decimal closePct = 0.3m;
const decimal carry = 0m; // 无历史归档
// 旧方法
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailyCompoundInterest(EndDate, position, Notional * closePct, flowEvent,
AnnualDays, false, 0m, closePct, Notional, true, false,
ref oldI, ref oldTd, consumedInterest: consumed, resetCarryInterest: carry);
// 新方法
var allInRate = Spread;
var segRates = new List<(DateTime, decimal)>
{
(StartDate, allInRate),
(StartDate.AddDays(7), allInRate),
(StartDate.AddDays(14), allInRate),
};
var result = FundingLegAccrual.AccrueCompoundPeriod(
principal: Notional * closePct,
segmentRates: segRates,
startDate: StartDate,
endDate: EndDate,
boundary: AccrualBoundary.StartOnly,
annualDays: AnnualDays,
isAnnualized: true,
resetCarryInterest: carry,
consumedInterest: consumed,
closePercent: closePct);
Console.WriteLine($"旧: I={oldI} Td={oldTd}");
Console.WriteLine($"新: Accrued={result.Accrued} AccruedToday={result.AccruedToday}");
Assert.AreEqual((double)oldI, (double)result.Accrued, 0.01, "InterestAmount 一致");
}
/// <summary>
/// 算头算尾(calcMode="11")对比。
/// </summary>
[TestMethod]
public void _算头算尾_旧新一致()
{
var position = CreatePosition();
var flowEvent = new swap_flow_event { InterestRate = Spread };
var preEod = new eod_swap_position { id = 0 };
// 旧方法
decimal oldI = 0, oldTd = 0;
var svc = new StubSvc();
svc.CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent,
AnnualDays, false, 0m, 1m, Notional, true, true,
ref oldI, ref oldTd);
// 新方法
var allInRate = Spread;
var segRates = new List<(DateTime, decimal)>
{
(StartDate, allInRate),
(StartDate.AddDays(7), allInRate),
(StartDate.AddDays(14), allInRate),
};
var result = FundingLegAccrual.AccrueCompoundPeriod(
principal: Notional,
segmentRates: segRates,
startDate: StartDate,
endDate: EndDate,
boundary: AccrualBoundary.Both,
annualDays: AnnualDays,
isAnnualized: true);
Console.WriteLine($"旧: I={oldI} Td={oldTd}");
Console.WriteLine($"新: Accrued={result.Accrued} AccruedToday={result.AccruedToday}");
Assert.AreEqual((double)oldI, (double)result.Accrued, 0.01, "InterestAmount 一致");
}
}
}
@@ -171,4 +171,92 @@ public static class FundingLegAccrual
Math.Round(interest, precision, MidpointRounding.AwayFromZero),
Math.Round(tdInterest, precision, MidpointRounding.AwayFromZero));
}
/// <summary>
/// 复利多日计息(纯函数,替换 CalcDailyCompoundInterest 的"纯数学"部分)。
///
/// 复利特征:从 PosiStartDate 到 endDate 全程重放,每个重置日把累计利息并入本金。
/// 分段模型:每段从重置日到下一个重置日(或 endDate),段内本金恒定。
///
/// 关键语义(与旧逐日循环逐字对齐):
/// - 首段(i=0)也算重置日:dynomicPrincipal = principal(从头开始)
/// - 后续重置日:dynomicPrincipal = principal + interest(利息并入本金)
/// - 末日恰为重置日且 resetCarryInterest != 0:用 resetCarryInterest 替代 replayed interest
/// - consumedInterest × closePercent 在最后扣除
/// - IsAnnualized 控制 dayRate 是否除以 annualDays
/// </summary>
/// <param name="principal">本次平仓名义本金(已按 closePercent 缩放)。</param>
/// <param name="segmentRates">分段利率表:(段起日, all-in利率 = spread + FR007),按日期升序。段起日 = PosiStartDate + k×period。</param>
/// <param name="startDate">PosiStartDate。</param>
/// <param name="endDate">平仓日。</param>
/// <param name="boundary">算头算尾。</param>
/// <param name="annualDays">年化天数。</param>
/// <param name="isAnnualized">是否年化。</param>
/// <param name="resetCarryInterest">末日重置时的存量利息(非末日传 0)。</param>
/// <param name="consumedInterest">历史已结利息(全程绝对值)。</param>
/// <param name="closePercent">平仓比例。</param>
public static InterestResult AccrueCompoundPeriod(
decimal principal,
IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates,
DateTime startDate,
DateTime endDate,
AccrualBoundary boundary,
int annualDays,
bool isAnnualized,
decimal resetCarryInterest = 0m,
decimal consumedInterest = 0m,
decimal closePercent = 1m)
{
var precision = SwapInterest.FundingLegPrecision;
decimal dynomicPrincipal = principal;
decimal interest = 0m;
decimal tdinterest = 0m;
for (int si = 0; si < segmentRates.Count; si++)
{
var (segStart, segRate) = segmentRates[si];
var segEnd = si < segmentRates.Count - 1
? segmentRates[si + 1].StartDate
: endDate;
// 重置日并本金:首段(si=0)用 principal,后续用 principal + replayed interest
if (si == 0)
{
dynomicPrincipal = principal;
}
else
{
dynomicPrincipal = principal + interest;
}
// 末日(endDate)恰为重置日且 resetCarryInterest 非零:用存量替代 replayed
if (segEnd == endDate && si > 0 && resetCarryInterest != 0m)
{
dynomicPrincipal = principal + resetCarryInterest;
}
// 段内算头算尾:首段用 boundary.IncludeStart,后续段不算头
var segIncludeStart = (si == 0) ? boundary.IncludeStart : false;
var segIncludeEnd = (segEnd == endDate) ? boundary.IncludeEnd : true;
var segBoundary = AccrualBoundary.Of(segIncludeStart, segIncludeEnd);
var days = SwapInterest.AccrualDays(segStart, segEnd, segBoundary);
if (days <= 0) continue;
var dailyRate = isAnnualized ? segRate / annualDays : segRate;
var daily = Math.Round(dynomicPrincipal * dailyRate, precision, MidpointRounding.AwayFromZero);
var segInterest = Math.Round(daily * days, precision, MidpointRounding.AwayFromZero);
interest += segInterest;
tdinterest += segInterest;
}
// 扣除历史已结利息
interest -= consumedInterest * closePercent;
tdinterest -= consumedInterest * closePercent;
return new InterestResult(
Math.Round(interest, precision, MidpointRounding.AwayFromZero),
Math.Round(tdinterest, precision, MidpointRounding.AwayFromZero));
}
}