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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user