fix(swap): EQD-6977 无preEod兜底①=0且账龄超重置周期补退化告警trace——interestWindowEmpty(当日已结息)早退不重放覆盖种子值时①静默0(重置日分支本有同类告警,段中分支漏)复利每周期并本理应①>0;trace含事件基数/平仓本金两侧对比值+账龄天数+重置周期+疑似成因指向(interestWindowEmpty种子未重放/日终归档缺失)足够事后定位根因;承接块抽为ResolveCompoundCarry私有纯方法(Merge瘦身,三分支数据契约注释随行,行为零变化);TDD先行3例trace钉子:退化必告警(先红)/兜底>0不告警/真首日不告警;罚息域27例+GetInterests入口语义9例全绿

This commit is contained in:
hjhan
2026-08-21 07:10:04 +08:00
parent 8b66875098
commit 6e76f1c10d
2 changed files with 96 additions and 35 deletions
@@ -50,7 +50,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
private static void RunMerge(
swap_position p, swap_flow_event normalEvent, eod_swap_position? preEod,
Func<swap_position, decimal>? getSpread = null, Func<DateTime, string, decimal?>? tryGetFixing = null)
Func<swap_position, decimal>? getSpread = null, Func<DateTime, string, decimal?>? tryGetFixing = null,
AccrualTrace? trace = null)
{
getSpread ??= _ => Rate;
tryGetFixing ??= (d, code) => Rate;
@@ -61,7 +62,8 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
posiNotionalValue: Notional, closePosiNotionalValue: Notional, closePercent: 1m,
getSpread: getSpread,
getPreEod: _ => preEod,
tryGetFixing: tryGetFixing);
tryGetFixing: tryGetFixing,
trace: trace);
}
/// <summary>复利重放 [StartDate, endDate],重置段=每 7 天;分段利率由 rates 决定(rates.Count=1 时为常率)。</summary>
@@ -152,6 +154,45 @@ namespace UnitTestProject.Modules.SwapModule.Penalty
Assert.IsTrue(e.InterestFee > 0m, "无 preEod(首日平仓等)仍可计算罚息");
}
[TestMethod]
public void preEod复利段中兜底为零且账龄超重置周期_留退化告警trace()
{
// 场景:无日终快照 + 复利 + 段中平仓,事件 InterestPrincipal 仍是种子值(=平仓本金)→兜底①=0。
// 账龄 25 天 ≥ 7 天重置周期:复利每周期并本理应①>0,①=0 属退化——
// 典型成因=interestWindowEmpty(当日已结息)早退未重放覆盖种子、或日终归档缺失。
var e = NormalEvent(settledAmount: 50_000m);
e.InterestPrincipal = Notional; // GetInterests 种子值:interestWindowEmpty 早退路径不会用重放基数覆盖它
var trace = new AccrualTrace();
RunMerge(Leg(InterestTypeEnum.), e, preEod: null, trace: trace);
StringAssert.Contains(trace.ToString(), "无preEod兜底①=0",
"①=0 且账龄超周期必须留告警,供事后核对日终归档/计息窗口根因");
}
[TestMethod]
public void preEod兜底为正_不留退化告警()
{
var e = NormalEvent(settledAmount: 50_000m);
e.InterestPrincipal = Notional + 100_000m; // 重放末次并本金后基数 → ①=100000 正常路径
var trace = new AccrualTrace();
RunMerge(Leg(InterestTypeEnum.), e, preEod: null, trace: trace);
Assert.IsFalse(trace.ToString().Contains("兜底①=0"), "①>0 是正常兜底路径,不得告警");
}
[TestMethod]
public void preEod真首日兜底为零_不留退化告警()
{
var p = Leg(InterestTypeEnum.);
p.PosiStartDate = UnwindDate; // 起息日当天平仓:账龄 0 < 重置周期,①=0 是设计内约定(类头注)
var e = NormalEvent(settledAmount: 50_000m);
e.InterestPrincipal = Notional;
var trace = new AccrualTrace();
RunMerge(p, e, preEod: null, trace: trace);
Assert.IsFalse(trace.ToString().Contains("兜底①=0"), "真首日 ①=0 合法,不得告警");
}
[TestMethod]
public void _跳过该腿不阻断()
{
@@ -93,39 +93,9 @@ public static class PenaltyInterestFeeMerger
}
// 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。
// ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约):
// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差;
// 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest)
// 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值;
// 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取
// preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。
decimal capitalized = 0m, carryIn = 0m;
if (isCompound)
{
var periodDays = position.interest_rest_days ?? 1;
var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays);
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))
{
trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum");
capitalized = Math.Max(0m, normalEvent.InterestAmount);
}
carryIn = normalEvent.InterestAmount - capitalized;
}
var (capitalized, carryIn) = isCompound
? ResolveCompoundCarry(position, normalEvent, preEod, closePrincipal, share, unwindDate, trace)
: (0m, 0m);
var policy = AccrualPolicy.BuildEod(position, annualDays, isCompound);
// 锚点 = PosiStartDate:与正常计息重放(CalcDailyCompoundInterest 的分段网格)一致,延期腿勿用 td.StartDate
@@ -149,4 +119,54 @@ public static class PenaltyInterestFeeMerger
$"罚息={penalty:F2} → InterestFee {feeBefore:F2}→{normalEvent.InterestFee:F2} PnL含罚息={normalEvent.InterestClosePnL:F2}");
}
}
/// <summary>
/// 复利承接量:① 实际滚动基数中已并入部分 + ② 最近重置日后实际已计利息(= 实结 − ①)。
///
/// ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约):
/// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差;
/// 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest)
/// 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值;
/// 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取
/// preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。
/// </summary>
private static (decimal Capitalized, decimal CarryIn) ResolveCompoundCarry(
swap_position position, swap_flow_event normalEvent, eod_swap_position? preEod,
decimal closePrincipal, decimal share, DateTime unwindDate, AccrualTrace? trace)
{
var periodDays = position.interest_rest_days ?? 1;
var unwindOnResetDay = SwapDealService.IsResetDay(unwindDate, position.PosiStartDate, periodDays);
var capitalized = 0m;
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);
// 兜底①=0 但账龄已过重置周期:复利每周期并本,理应 ①>0——多为 interestWindowEmpty
// (当日已结息)早退未重放覆盖种子值、或日终归档缺失。留痕含两侧基数与账龄,供直接定位根因。
var ageDays = (unwindDate - position.PosiStartDate).Days;
if (capitalized == 0m && ageDays >= periodDays)
trace?.Note(
$"PENALTY|p{position.id} 注意 无preEod兜底①=0但账龄{ageDays}天≥重置周期{periodDays}天:" +
$"事件基数{normalEvent.InterestPrincipal:F2}=平仓本金{closePrincipal:F2}(疑似interestWindowEmpty种子未重放/日终归档缺失," +
$"请核对swap_flow_event.InterestPrincipal重放回写与eod_swap_position归档)");
}
// ① 不得超过实结金额(数据异常时钳制并留痕,避免负②进入计息)
if (capitalized > Math.Max(0m, normalEvent.InterestAmount))
{
trace?.Note($"PENALTY|p{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum");
capitalized = Math.Max(0m, normalEvent.InterestAmount);
}
return (capitalized, normalEvent.InterestAmount - capitalized);
}
}