diff --git a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs index 5df336c4..3a9ede19 100644 --- a/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs +++ b/UnitTestProject/Modules/SwapModule/Penalty/PenaltyInterestFeeMergerTest.cs @@ -157,40 +157,40 @@ namespace UnitTestProject.Modules.SwapModule.Penalty [TestMethod] public void 无preEod复利段中兜底为零且账龄超重置周期_留退化告警trace() { - // 场景:无日终快照 + 复利 + 段中平仓,事件 InterestPrincipal 仍是种子值(=平仓本金)→兜底①=0。 - // 账龄 25 天 ≥ 7 天重置周期:复利每周期并本理应①>0,①=0 属退化—— + // 场景:无日终快照 + 复利 + 段中平仓,事件 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 且账龄超周期必须留告警,供事后核对日终归档/计息窗口根因"); + StringAssert.Contains(trace.ToString(), "无preEod兜底已并复利本金=0", + "已并复利本金=0 且账龄超周期必须留告警,供事后核对日终归档/计息窗口根因"); } [TestMethod] public void 无preEod兜底为正_不留退化告警() { var e = NormalEvent(settledAmount: 50_000m); - e.InterestPrincipal = Notional + 100_000m; // 重放末次并本金后基数 → ①=100000 正常路径 + 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 是正常兜底路径,不得告警"); + Assert.IsFalse(trace.ToString().Contains("兜底已并复利本金=0"), "已并复利本金>0 是正常兜底路径,不得告警"); } [TestMethod] public void 无preEod真首日兜底为零_不留退化告警() { var p = Leg(InterestTypeEnum.复利); - p.PosiStartDate = UnwindDate; // 起息日当天平仓:账龄 0 < 重置周期,①=0 是设计内约定(类头注) + 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 合法,不得告警"); + Assert.IsFalse(trace.ToString().Contains("兜底已并复利本金=0"), "真首日 已并复利本金=0 合法,不得告警"); } [TestMethod] diff --git a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs index 54289c5b..59b64956 100644 --- a/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs +++ b/YLErpDAL/Modules/SwapModule/Penalty/PenaltyInterestFeeMerger.cs @@ -12,9 +12,9 @@ namespace YLErp.Modules.SwapModule.Penalty; /// getSpread(加点利差)/ getPreEod(上一日终快照行)/ tryGetFixing(定盘取价),本类零 DB 耦合、可 headless 单测。 /// /// 复利承接量(精确续接口径的关键)**必须取实际计息状态**,严禁冻结利率重放推导: -/// 承接① capitalized = max(0, preEod.TdInterestPrincipal×份额 − closePrincipal) —— 实际滚动复利基数中已并入部分; -/// 承接② carryIn = 正常平仓流实结 InterestAmount − ① —— 最近重置日后实际已计利息; -/// 无 preEod(首日平仓):①=0、②=实结金额。 +/// 已并复利本金 capitalized = max(0, preEod.TdInterestPrincipal×份额 − closePrincipal) —— 实际滚动复利基数中已并入部分; +/// 段内已计利息 carryIn = 正常平仓流实结 InterestAmount − 已并复利本金 —— 最近重置日后实际已计利息; +/// 无 preEod(首日平仓):已并复利本金=0、段内已计利息=实结金额。 /// 逐腿全程 trace 落盘(SwapCalcTrace),供计算过程分析与错误定位。 /// public static class PenaltyInterestFeeMerger @@ -48,7 +48,7 @@ public static class PenaltyInterestFeeMerger foreach (var position in fundingPositions) { - // 正常平仓利息流(GetInterests 刚产出)——承接②的事实源与罚息并入目标 + // 正常平仓利息流(GetInterests 刚产出)——段内已计利息的事实源与罚息并入目标 var normalEvent = interests.FirstOrDefault(x => x.PositionId == position.id); if (normalEvent == null) { @@ -92,7 +92,7 @@ public static class PenaltyInterestFeeMerger continue; } - // 复利承接:实际滚动基数中已并入部分(①)+ 段内实际已计利息(②)。单利无并本金语义恒 0。 + // 复利承接:实际滚动基数中已并入部分(已并复利本金)+ 段内实际已计利息(段内已计利息)。单利无并本金语义恒 0。 var (capitalized, carryIn) = isCompound ? ResolveCompoundCarry(position, normalEvent, preEod, closePrincipal, share, unwindDate, trace) : (0m, 0m); @@ -115,18 +115,18 @@ public static class PenaltyInterestFeeMerger $"窗口=[{unwindDate:yyyy-MM-dd}→{maturityDate:yyyy-MM-dd}] 平仓日已结={unwindDaySettled} 到期算尾={maturityCalcLast} | " + $"本金 close={closePrincipal:F2} posi={r.PosiPrincipal:F2} share={share:P4} | " + $"冻结利率={frozenRate.AllInRate:P6} 来源={rateSource} | " + - $"承接①={capitalized:F4} ②={carryIn:F4} 实结={normalEvent.InterestAmount:F4} | " + + $"承接[已并复利本金]={capitalized:F4} [段内已计利息]={carryIn:F4} 实结={normalEvent.InterestAmount:F4} | " + $"罚息={penalty:F2} → InterestFee {feeBefore:F2}→{normalEvent.InterestFee:F2} PnL含罚息={normalEvent.InterestClosePnL:F2}"); } } /// - /// 复利承接量:① 实际滚动基数中已并入部分 + ② 最近重置日后实际已计利息(= 实结 − ①)。 + /// 复利承接量:已并复利本金(实际滚动基数中已并入部分)+ 段内已计利息(最近重置日后实际已计,= 实结 − 已并复利本金)。 /// - /// ① 的取值依赖平仓日是否为重置日、有无日终快照(数据契约): - /// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+①),直接作差; + /// 已并复利本金的取值依赖平仓日是否为重置日、有无日终快照(数据契约): + /// 段中平仓 + 有快照:TdInterestPrincipal 即当前段滚动基数(=本金+已并复利本金),直接作差; /// 段中平仓 + 无快照:兜底取 normalEvent.InterestPrincipal——复利重放(CalcDailyCompoundInterest) - /// 会把它写为末次并本金后的基数(=被平份额本金+①),同样是实际值而非推导值; + /// 会把它写为末次并本金后的基数(=被平份额本金+已并复利本金),同样是实际值而非推导值; /// 重置日当天平仓:快照基数仍是【上一段】的(今日并入尚未发生),须改取 /// preEod.InterestIncomeSum(昨日全部待实现利息 = 今日并入新段基数的那部分)。 /// @@ -143,41 +143,41 @@ public static class PenaltyInterestFeeMerger capitalized = (preEod?.InterestIncomeSum ?? 0m) * share; if (preEod != null) trace?.Note( - $"PENALTY|融资腿{position.id} 承接①推导 重置日平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + - $"昨日待实现利息InterestIncomeSum={preEod.InterestIncomeSum:F4} ×share={share:P4} → ①={capitalized:F4}"); + $"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 重置日平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + + $"昨日待实现利息InterestIncomeSum={preEod.InterestIncomeSum:F4} ×share={share:P4} → 已并复利本金={capitalized:F4}"); if (preEod == null && (unwindDate - position.PosiStartDate).Days >= periodDays) - trace?.Note($"PENALTY|融资腿{position.id} 注意 无preEod且平仓日=重置日:①退化0(此前重置并入额缺失,请核对日终归档完整性)"); + trace?.Note($"PENALTY|融资腿{position.id} 注意 无preEod且平仓日=重置日:已并复利本金退化0(此前重置并入额缺失,请核对日终归档完整性)"); } else if (preEod != null) { - // 段中平仓+有快照(复利承接主路径):① = 快照滚动基数×份额 − 平仓本金。全程留推导—— - // ①异常时凭此行即可区分"快照基数错 / share错 / 平仓本金错"三因,不必反推。 + // 段中平仓+有快照(复利承接主路径):已并复利本金 = 快照滚动基数×份额 − 平仓本金。全程留推导—— + // 结果异常时凭此行即可区分"快照基数错 / share错 / 平仓本金错"三因,不必反推。 var rawCarry = preEod.TdInterestPrincipal * share - closePrincipal; capitalized = Math.Max(0m, rawCarry); trace?.Note( - $"PENALTY|融资腿{position.id} 承接①推导 段中平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + - $"滚动基数TdInterestPrincipal={preEod.TdInterestPrincipal:F4} ×share={share:P4} −平仓本金{closePrincipal:F4} = {rawCarry:F4} → ①={capitalized:F4}" + + $"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 段中平仓+有快照:快照{preEod.ValueDate:yyyy-MM-dd} " + + $"滚动基数TdInterestPrincipal={preEod.TdInterestPrincipal:F4} ×share={share:P4} −平仓本金{closePrincipal:F4} = {rawCarry:F4} → 已并复利本金={capitalized:F4}" + (rawCarry < 0m ? "(原始差为负已钳0:快照滚动基数×份额小于平仓本金,疑部分平仓比例与快照归档口径不一致,请核对eod_swap_position.TdInterestPrincipal)" : "")); } else { capitalized = Math.Max(0m, normalEvent.InterestPrincipal - closePrincipal); trace?.Note( - $"PENALTY|融资腿{position.id} 承接①推导 段中平仓+无快照兜底:事件基数InterestPrincipal={normalEvent.InterestPrincipal:F4} −平仓本金{closePrincipal:F4} → ①={capitalized:F4}"); - // 兜底①=0 但账龄已过重置周期:复利每周期并本,理应 ①>0——多为 interestWindowEmpty + $"PENALTY|融资腿{position.id} 承接量推导(已并复利本金) 段中平仓+无快照兜底:事件基数InterestPrincipal={normalEvent.InterestPrincipal:F4} −平仓本金{closePrincipal:F4} → 已并复利本金={capitalized:F4}"); + // 兜底已并复利本金=0 但账龄已过重置周期:复利每周期并本,理应 >0——多为 interestWindowEmpty // (当日已结息)早退未重放覆盖种子值、或日终归档缺失。留痕含两侧基数与账龄,供直接定位根因。 var ageDays = (unwindDate - position.PosiStartDate).Days; if (capitalized == 0m && ageDays >= periodDays) trace?.Note( - $"PENALTY|融资腿{position.id} 注意 无preEod兜底①=0但账龄{ageDays}天≥重置周期{periodDays}天:" + + $"PENALTY|融资腿{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|融资腿{position.id} 注意 承接①钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); + trace?.Note($"PENALTY|融资腿{position.id} 注意 承接已并复利本金钳制:推导 {capitalized:F4} > 实结 {normalEvent.InterestAmount:F4}(快照/事件数据异常,请核对 preEod.TdInterestPrincipal/InterestIncomeSum)"); capitalized = Math.Max(0m, normalEvent.InterestAmount); } return (capitalized, normalEvent.InterestAmount - capitalized); diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 0a238637..25376977 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -905,13 +905,13 @@ namespace YLErp.Modules.SwapModule isResetDay ? endDate : startDate, position.interest_rule); // 历史上有"取错重置日利率"的线上 bug,取价决策必须常驻落盘(SwapCalcTrace.Critical 无条件 Info)。 SwapCalcTrace.Critical( - $"FIX GetFloatRate p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] days={days} period={period} " + + $"FIX GetFloatRate 融资腿{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] days={days} period={period} " + $"重置日={isResetDay} rule={position.interest_rule} 取价日={rateDate:yyyy-MM-dd} calcLast={calcLast} " + $"preEod={(preEod.id != 0 ? $"{preEod.ValueDate:yyyy-MM-dd}:{preEod.FloatRate:P6}" : "无")}"); if (preEod.id != 0 && !isResetDay) { - SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 非重置日→沿用昨日终FloatRate={preEod.FloatRate:P6}"); + SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} 非重置日→沿用昨日终FloatRate={preEod.FloatRate:P6}"); position.FloatRate = positionClone.FloatRate = preEod.FloatRate; return preEod.FloatRate; } @@ -923,13 +923,13 @@ namespace YLErp.Modules.SwapModule { var keptNoFetch = preEod.id != 0 ? preEod.FloatRate : position.FloatRate; SwapCalcTrace.Critical( - $"FIX GetFloatRate p{position.id} 不算尾重置日→不取尾日定盘,沿用已有利率={keptNoFetch:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); + $"FIX GetFloatRate 融资腿{position.id} 不算尾重置日→不取尾日定盘,沿用已有利率={keptNoFetch:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); return keptNoFetch; } if (IndexFixer.TryGetFixing(rateDate, position.FloatRateUnderlyingCode, out decimal rate)) { - SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 重置日→取{rateDate:yyyy-MM-dd}定盘={rate:P6}"); + SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} 重置日→取{rateDate:yyyy-MM-dd}定盘={rate:P6}"); position.FloatRate = positionClone.FloatRate = rate; return position.FloatRate; } @@ -937,17 +937,17 @@ namespace YLErp.Modules.SwapModule { if (calcLast) { - SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且算尾→抛异常拦截"); + SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} {rateDate:yyyy-MM-dd}缺价且算尾→抛异常拦截"); throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格"); } // 算头不算尾(calcLast=false):endDate 当天不计息,其 FR007 利率不参与计息, // 缺价时直接沿用已有利率,不回退取其他日期利率,不告警。 var kept = preEod.id != 0 ? preEod.FloatRate : position.FloatRate; SwapCalcTrace.Critical( - $"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且不算尾→沿用已有利率={kept:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); + $"FIX GetFloatRate 融资腿{position.id} {rateDate:yyyy-MM-dd}缺价且不算尾→沿用已有利率={kept:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})"); return kept; } - SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 计息窗口为空→利率不参与,返回0"); + SwapCalcTrace.Critical($"FIX GetFloatRate 融资腿{position.id} 计息窗口为空→利率不参与,返回0"); return 0m; } @@ -1376,15 +1376,15 @@ namespace YLErp.Modules.SwapModule if (fixing != 0m) { SwapCalcTrace.Critical( - $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→定盘={fixing:P6}"); + $"FIX Resolve 融资腿{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→定盘={fixing:P6}"); return fixing; } SwapCalcTrace.Critical( - $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd})→定盘=0视为缺价,沿用fallback={fallback:P6}"); + $"FIX Resolve 融资腿{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd})→定盘=0视为缺价,沿用fallback={fallback:P6}"); return fallback; } SwapCalcTrace.Critical( - $"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→缺价,抛异常"); + $"FIX Resolve 融资腿{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→缺价,抛异常"); throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); } @@ -1403,7 +1403,7 @@ namespace YLErp.Modules.SwapModule var calcDays = (endDate - startDate).Days; decimal currentFloat = initialFloat; SwapCalcTrace.Critical( - $"FIX Segments p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] period={interestPeriod} " + + $"FIX Segments 融资腿{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] period={interestPeriod} " + $"fetchAfter={(fetchAfterDate?.ToString("yyyy-MM-dd") ?? "全程")} calcLast={calcLast} " + $"排除起点={(exclusionStart?.ToString("yyyy-MM-dd") ?? (calcLast ? "无" : endDate.ToString("yyyy-MM-dd")))} seed={initialFloat:P6} spread={spread:P6}"); for (int i = 0; i <= calcDays; i += interestPeriod) @@ -1422,7 +1422,7 @@ namespace YLErp.Modules.SwapModule else if (needFetch && isExcludedEnd) { SwapCalcTrace.Critical( - $"FIX Segment p{position.id} {resetDate:yyyy-MM-dd} 排除日(不计息)→不取价,沿用末段={currentFloat:P6}"); + $"FIX Segment 融资腿{position.id} {resetDate:yyyy-MM-dd} 排除日(不计息)→不取价,沿用末段={currentFloat:P6}"); } rates.Add((resetDate, spread + currentFloat)); } diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 714ba36d..f81b2f7f 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1380,7 +1380,7 @@ namespace YLErp.Modules.SwapModule { var ongoingFixing = ResolveOngoingResetFixing(position, valueDate); SwapCalcTrace.Critical( - $"FIX EodCloseRefix p{position.id} {valueDate:yyyy-MM-dd} 平仓日=重置日→剩余持仓快照再定盘 {newEodPayPosition.FloatRate:P6}→{ongoingFixing:P6}"); + $"FIX EodCloseRefix 融资腿{position.id} {valueDate:yyyy-MM-dd} 平仓日=重置日→剩余持仓快照再定盘 {newEodPayPosition.FloatRate:P6}→{ongoingFixing:P6}"); newEodPayPosition.FloatRate = ongoingFixing; } //利息端估值用信息