fix(swap): EQD-6977 无日终快照时承接①兜底取事件基数——复利重放把 normalEvent.InterestPrincipal 写为末次并本金后基数(=被平份额本金+①),同为实际值;UAT实测(无快照+已有8/19重置)原①=0少算≈3.17元已消除;重置日+无快照退化场景补告警trace;新增兜底路径恒等式测试(23/23)

This commit is contained in:
hjhan
2026-08-20 16:09:43 +08:00
parent 8d7bc1b457
commit 286a834aa5
2 changed files with 45 additions and 10 deletions
@@ -49,14 +49,14 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
ExerciseDate = maturity, TradeStatus = "确认成交", ValidState = "Valid"
};
private static swap_position CompoundLeg(DateTime posiStart, DateTime maturity, decimal spread)
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 = 7, interest_rule = 0,
IsAnnualized = true, interest_rest_days = periodDays, interest_rule = 0,
FloatRateUnderlyingCode = null, InterestSwapInterval = "[]"
};
@@ -66,16 +66,18 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
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)
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
InterestDirection = 1, InterestClosePnL = settledAmount,
InterestPrincipal = interestPrincipal // 复利主路径下=重放末次并本金后基数(=被平份额本金+①)
};
PenaltyInterestFeeMerger.Merge(
td, new List<swap_position> { p }, new List<swap_flow_event> { e },
unwind, AnnualDays, settled, maturityCalcLast: true,
unwind, AnnualDays, settled, maturityCalcLast: maturityCalcLast,
posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m,
getSpread: _ => spread, getPreEod: _ => preEod, tryGetFixing: (d, c) => spread);
return e.InterestFee;
@@ -170,6 +172,26 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
"锚点偏离:罚息分段/重置日判定必须用 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_恒等式成立()
{
@@ -89,8 +89,10 @@ public static class PenaltyInterestFeeMerger
}
// 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。
// ① 的取值依赖平仓日是否为重置日(数据契约):
// 段中平仓:昨日快照 TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差;
// ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约):
// 段中平仓 + 有快照TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差;
// 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest)
// 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值;
// 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取
// preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。
decimal capitalized = 0m, carryIn = 0m;
@@ -98,9 +100,20 @@ public static class PenaltyInterestFeeMerger
{
var periodDays = position.interest_rest_days ?? 1;
var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays);
capitalized = unwindOnResetDay
? (preEod?.InterestIncomeSum ?? 0m) * share
: Math.Max(0m, (preEod?.TdInterestPrincipal ?? 0m) * share - closePrincipal);
if (unwindOnResetDay)
{
capitalized = (preEod?.InterestIncomeSum ?? 0m) * share;
if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays)
trace?.Note($"PENALTY|p{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)");
}
else if (preEod != null)
{
capitalized = Math.Max(0m, preEod.TdInterestPrincipal * share - closePrincipal);
}
else
{
capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal);
}
// ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息)
if (capitalized > Math.Max(0m, normalEvent.InterestAmount))
{