From c86633479dcd49a493355bd9bc750e9133a63e10 Mon Sep 17 00:00:00 2001 From: hjhan Date: Wed, 12 Aug 2026 13:09:17 +0800 Subject: [PATCH] =?UTF-8?q?refactor(accrual):=20=E6=8E=A5=E7=BA=BFSimpleIn?= =?UTF-8?q?terest/CompoundInterest=E5=86=85=E9=83=A8=20+=20=E5=88=86?= =?UTF-8?q?=E6=AE=B5=E4=BF=AE=E5=A4=8D=20+=20=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CalcDailySimpleInterest/CalcDailyCompoundInterest内部逐日循环→分段纯函数(签名不变): - AccrueSimplePeriod: AccruedToday改未缩放累计(TdInterestAmount口径) - AccrueCompoundPeriod: 新增out finalBasis供flowEvent.InterestPrincipal精确赋值 - 修复5处分段边界bug(includeStart/includeEnd/resetCarryInterest interestPeriod=1场景) - 提取BuildSegmentRates消除SimpleInterest/CompoundInterest取率重复 - 影子测试补AccruedToday断言关闭测试盲区 SwapModule零回归(7基线/510通过) --- .../Accrual/CompoundPeriodShadowTest.cs | 18 +- .../Accrual/SimplePeriodShadowTest.cs | 86 +++++++ .../SwapModule/Accrual/FundingLegAccrual.cs | 44 ++-- .../Modules/SwapModule/SwapDealService.cs | 223 ++++++++---------- 4 files changed, 224 insertions(+), 147 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs index d1fd0a26..ce55867b 100644 --- a/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs +++ b/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs @@ -93,11 +93,16 @@ namespace UnitTestProject.Modules.SwapModule.Accrual endDate: EndDate, boundary: AccrualBoundary.StartOnly, annualDays: AnnualDays, - isAnnualized: true); + isAnnualized: true, + resetCarryInterest: 0m, + realizedInterest: 0m, + unwindFraction: 1m, + finalBasis: out _); Console.WriteLine($"旧: I={oldI} Td={oldTd}"); Console.WriteLine($"新: Accrued={result.Accrued} AccruedToday={result.AccruedToday}"); Assert.AreEqual((double)oldI, (double)result.Accrued, 0.01, "InterestAmount 一致"); + Assert.AreEqual((double)oldTd, (double)result.AccruedToday, 0.01, "TdInterestAmount 一致"); } /// @@ -138,11 +143,13 @@ namespace UnitTestProject.Modules.SwapModule.Accrual isAnnualized: true, resetCarryInterest: carry, realizedInterest: consumed, - unwindFraction: closePct); + unwindFraction: closePct, + finalBasis: out _); Console.WriteLine($"旧: I={oldI} Td={oldTd}"); Console.WriteLine($"新: Accrued={result.Accrued} AccruedToday={result.AccruedToday}"); Assert.AreEqual((double)oldI, (double)result.Accrued, 0.01, "InterestAmount 一致"); + Assert.AreEqual((double)oldTd, (double)result.AccruedToday, 0.01, "TdInterestAmount 一致"); } /// @@ -177,11 +184,16 @@ namespace UnitTestProject.Modules.SwapModule.Accrual endDate: EndDate, boundary: AccrualBoundary.Both, annualDays: AnnualDays, - isAnnualized: true); + isAnnualized: true, + resetCarryInterest: 0m, + realizedInterest: 0m, + unwindFraction: 1m, + finalBasis: out _); Console.WriteLine($"旧: I={oldI} Td={oldTd}"); Console.WriteLine($"新: Accrued={result.Accrued} AccruedToday={result.AccruedToday}"); Assert.AreEqual((double)oldI, (double)result.Accrued, 0.01, "InterestAmount 一致"); + Assert.AreEqual((double)oldTd, (double)result.AccruedToday, 0.01, "TdInterestAmount 一致"); } } } diff --git a/UnitTestProject/Modules/SwapModule/Accrual/SimplePeriodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/SimplePeriodShadowTest.cs index 6d42707d..d932e035 100644 --- a/UnitTestProject/Modules/SwapModule/Accrual/SimplePeriodShadowTest.cs +++ b/UnitTestProject/Modules/SwapModule/Accrual/SimplePeriodShadowTest.cs @@ -64,6 +64,27 @@ namespace UnitTestProject.Modules.SwapModule.Accrual public StubSvc() : base(new OptUserInfo(0, nameof(SimplePeriodShadowTest), OptUserFrom.UnitTest)) { } } + /// 带浮动率 stub 的 SwapDealService:override IndexFixer 注入预设 FR007 取价。 + private sealed class FloatStubSvc : SwapDealService + { + private readonly IIndexFixer _fixer; + public FloatStubSvc(IIndexFixer fixer) : base(new OptUserInfo(0, nameof(SimplePeriodShadowTest), OptUserFrom.UnitTest)) + => _fixer = fixer; + protected override IIndexFixer IndexFixer => _fixer; + } + + /// Stub IIndexFixer:对所有查询返回固定 fixing(不依赖日期匹配,规避 QDP 日历差异)。 + private sealed class StubIndexFixer : IIndexFixer + { + private readonly decimal _rate; + public StubIndexFixer(decimal rate) => _rate = rate; + public bool TryGetFixing(DateTime fixingDate, string underlyingCode, out decimal rate) + { + rate = _rate; + return true; + } + } + /// /// 固定利率(无FR007)算头不算尾,全平,无历史归档。 /// @@ -100,6 +121,7 @@ namespace UnitTestProject.Modules.SwapModule.Accrual Console.WriteLine($"旧: I={oldI} Td={oldTd}"); Console.WriteLine($"新: Accrued={result.Accrued} AccruedToday={result.AccruedToday}"); Assert.AreEqual((double)oldI, (double)result.Accrued, 0.01, "InterestAmount 一致"); + Assert.AreEqual((double)oldTd, (double)result.AccruedToday, 0.01, "TdInterestAmount 一致"); } /// @@ -145,6 +167,70 @@ namespace UnitTestProject.Modules.SwapModule.Accrual Console.WriteLine($"旧: I={oldI} Td={oldTd}"); Console.WriteLine($"新: Accrued={result.Accrued} AccruedToday={result.AccruedToday}"); Assert.AreEqual((double)oldI, (double)result.Accrued, 0.01, "InterestAmount 一致"); + Assert.AreEqual((double)oldTd, (double)result.AccruedToday, 0.01, "TdInterestAmount 一致"); + } + + /// + /// 单利 + FR007 浮动利率 + 多重置日 + 部分平仓 + 有历史归档。 + /// 验证旧方法内部取价循环生成的 segmentRates 与手算一致——为抽取共享 SegmentRateBuilder 做安全网。 + /// 场景:preEod.ValueDate=4/30,重置日 4/21(跳过取价), 4/28(跳过取价), 5/5(取 FR007 fixing)。 + /// + [TestMethod] + public void 影子_单利浮动_FR007_部分平仓_旧新一致() + { + const decimal floatRateIn = 0.0150m; // 入参 floateRate(上一次取到的浮动率 1.50%) + const decimal fixingAtReset = 0.0125m; // 5/5 重置日取到的 FR007 fixing(1.25%) + const decimal closePct = 0.5m; + var preEodDate = new DateTime(2026, 4, 30); // 上一日终=4/30,5/5 > 4/30 触发取价 + + var position = CreatePosition(); + position.FloatRateUnderlyingCode = "FR007"; + + var preEod = new eod_swap_position + { + id = 1, SwapTradeId = 1, PositionId = 1001, + ValueDate = preEodDate, + TdInterestPrincipal = Notional, + InterestProfitSum = 200_000m, + PosiNotionalValue = Notional, FloatRate = 0m + }; + var flowEvent = new swap_flow_event { InterestRate = Spread }; + + // 旧方法(通过 stub IndexFixer 注入 FR007 取价) + decimal oldI = 0, oldTd = 0; + var svc = new FloatStubSvc(new StubIndexFixer(fixingAtReset)); + svc.CalcDailySimpleInterest(preEod, EndDate, position, Notional, flowEvent, + AnnualDays, false, floatRateIn, closePct, Notional, true, false, ref oldI, ref oldTd); + + // 新方法:手算 segmentRates(对齐旧代码取价循环的逻辑) + // 4/21 <= preEodDate(4/30) → 跳过取价,currentFloat 保持入参 floatRateIn + // 4/28 <= preEodDate(4/30) → 跳过取价,currentFloat 保持入参 floatRateIn + // 5/5 > preEodDate(4/30) → 取价,currentFloat 更新为 fixingAtReset + var segRates = new List<(DateTime, decimal)> + { + (StartDate, Spread + floatRateIn), // (4/21, 0.0175) + (StartDate.AddDays(7), Spread + floatRateIn), // (4/28, 0.0175) + (StartDate.AddDays(14), Spread + fixingAtReset), // (5/5, 0.0150) + }; + + // 差分本金 = preEod.TdInterestPrincipal + posiPrincipal - orginPv + var accrualPrincipal = Notional + Notional - Notional; + var result = FundingLegAccrual.AccrueSimplePeriod( + priorAccrued: 200_000m * closePct, + notional: accrualPrincipal, + unwindFraction: closePct, + segmentRates: segRates, + startDate: StartDate, + endDate: EndDate, + priorValueDate: preEodDate, + 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 一致"); + Assert.AreEqual((double)oldTd, (double)result.AccruedToday, 0.01, "TdInterestAmount 一致"); } } } diff --git a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs b/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs index 4e3cd113..90d3500d 100644 --- a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs +++ b/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs @@ -104,6 +104,7 @@ public static class FundingLegAccrual /// /// 单利多日计息(替换 CalcDailySimpleInterest 的纯数学部分)。 /// 本金全程恒定,按重置日分段取利率。 + /// Accrued = 缩放累计(InterestAmount),AccruedToday = 未缩放累计(TdInterestAmount)。 /// public static InterestResult AccrueSimplePeriod( decimal priorAccrued, @@ -119,12 +120,12 @@ public static class FundingLegAccrual AccrualTrace? trace = null) { var displayBasis = notional * unwindFraction; - decimal accrued = priorAccrued; + decimal accrued = priorAccrued; // 缩放累计 → InterestAmount + decimal accruedUnscaled = priorAccrued; // 未缩放累计 → TdInterestAmount trace?.MarkStart(startDate, endDate, boundary, annualDays, isAnnualized); var segStart = startDate; - var segIncludeStart = boundary.IncludeStart; for (int si = 0; si < segmentRates.Count; si++) { @@ -135,22 +136,29 @@ public static class FundingLegAccrual var effectiveStart = segStart > priorValueDate ? segStart : priorValueDate.AddDays(1); if (effectiveStart > segEnd) { segStart = segEnd; continue; } - var segBoundary = AccrualBoundary.Of(segIncludeStart, segEnd == endDate && boundary.IncludeEnd); + // calcFirst 只跳过 startDate 本身;其余天(含重置日、ValueDate+1)只要 > ValueDate 恒纳入。 + // 与旧逐日循环一致:if (!calcFirst && accrueDate == startDate) continue 是唯一的首日跳过。 + // 中间段的 segIncludeStart 被 days<=0 跳过后误置 false,此处按 startDate 判定而非继承标记。 + var includeStart = effectiveStart == startDate ? boundary.IncludeStart : true; + // calcLast 只影响 endDate 本身——只有真正的末段(si==Count-1)才算尾, + // 不能用 segEnd==endDate 判断(interestPeriod=1 时中间段 segEnd 也可能==endDate)。 + var isLastSegment = si == segmentRates.Count - 1; + var segBoundary = AccrualBoundary.Of(includeStart, isLastSegment && boundary.IncludeEnd); var days = SwapInterest.AccrualDays(effectiveStart, segEnd, segBoundary); - if (days <= 0) { segStart = segEnd; segIncludeStart = false; continue; } + if (days <= 0) { segStart = segEnd; continue; } var dailyRate = isAnnualized ? segmentRates[si].Rate / annualDays : segmentRates[si].Rate; var segInterest = displayBasis * dailyRate * days; accrued += segInterest; + accruedUnscaled += notional * dailyRate * days; trace?.Segment(si, effectiveStart, segEnd, days, segmentRates[si].Rate, displayBasis, segInterest, accrued); segStart = segEnd; - segIncludeStart = false; } var result = new InterestResult( SwapInterest.Round(accrued, Precision), - SwapInterest.Round(accrued, Precision)); + SwapInterest.Round(accruedUnscaled, Precision)); trace?.MarkEnd(result.Accrued, result.AccruedToday); return result; } @@ -167,9 +175,10 @@ public static class FundingLegAccrual AccrualBoundary boundary, int annualDays, bool isAnnualized, - decimal resetCarryInterest = 0m, - decimal realizedInterest = 0m, - decimal unwindFraction = 1m, + decimal resetCarryInterest, + decimal realizedInterest, + decimal unwindFraction, + out decimal finalBasis, AccrualTrace? trace = null) { decimal accrualBasis = notional; @@ -179,16 +188,19 @@ public static class FundingLegAccrual for (int si = 0; si < segmentRates.Count; si++) { - var segEnd = si < segmentRates.Count - 1 - ? segmentRates[si + 1].StartDate - : endDate; + var isLastSegment = si == segmentRates.Count - 1; + var segEnd = isLastSegment + ? endDate + : segmentRates[si + 1].StartDate; // 重置日并本金 accrualBasis = si == 0 ? notional : notional + accrued; - // 末日重置且 carry 非零:用存量替代 + // 末日恰好是重置日且 carry 非零:用存量替代(旧代码 i%interestPeriod==0 && accrueDate==endDate)。 + // 注意:必须同时判断 startDate==endDate——endDate 非重置日时最后一段起点 < endDate,不应触发。 var usedCarry = false; - if (segEnd == endDate && si > 0 && resetCarryInterest != 0m) + if (isLastSegment && si > 0 && resetCarryInterest != 0m + && segmentRates[si].StartDate == endDate) { accrualBasis = notional + resetCarryInterest; usedCarry = true; @@ -200,7 +212,7 @@ public static class FundingLegAccrual // 半开区间:重置日归下一段(旧代码逐日循环中重置日先更新本金再算息) var segIncludeStart = (si == 0) ? boundary.IncludeStart : true; - var segIncludeEnd = (segEnd == endDate) ? boundary.IncludeEnd : false; + var segIncludeEnd = isLastSegment ? boundary.IncludeEnd : false; var days = SwapInterest.AccrualDays(segmentRates[si].StartDate, segEnd, AccrualBoundary.Of(segIncludeStart, segIncludeEnd)); if (days <= 0) continue; @@ -211,6 +223,8 @@ public static class FundingLegAccrual trace?.Segment(si, segmentRates[si].StartDate, segEnd, days, segmentRates[si].Rate, accrualBasis, segInterest, accrued); } + finalBasis = accrualBasis; + // 扣除历史已结利息 if (realizedInterest != 0m) trace?.Unwind(endDate, unwindFraction, realizedInterest * unwindFraction, accrued - realizedInterest * unwindFraction); diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 39f38529..367cef46 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -1,4 +1,4 @@ -using MoreLinq.Extensions; +using MoreLinq.Extensions; using Newtonsoft.Json; using YLErp.BLL; using YLErp.BLL.Eod; @@ -1325,6 +1325,40 @@ namespace YLErp.Modules.SwapModule } return interest; } + + /// + /// 按重置周期切分利率段,每段记录 all-in 利率(spread+fixing)。返回 (分段列表, 末段浮动利率)。 + /// fetchAfterDate: 仅该日期之后的重置日才取 FR007(单利传 ValueDate,复利传 null 全程取)。 + /// + private (List<(DateTime StartDate, decimal Rate)> Segments, decimal LastFloat) BuildSegmentRates( + DateTime startDate, DateTime endDate, int interestPeriod, + swap_position position, decimal spread, decimal initialFloat, + DateTime? fetchAfterDate) + { + var rates = new List<(DateTime, decimal)>(); + var calcDays = (endDate - startDate).Days; + decimal currentFloat = initialFloat; + for (int i = 0; i <= calcDays; i += interestPeriod) + { + var resetDate = startDate.AddDays(i); + if ((fetchAfterDate == null || resetDate > fetchAfterDate.Value) + && !string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) + { + var fixingDate = IndexFixerBase.GetFixingDate(resetDate, position.interest_rule); + if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) + { + if (fixing != 0m) currentFloat = fixing; + } + else + { + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); + } + } + rates.Add((resetDate, spread + currentFloat)); + } + return (rates, currentFloat); + } + /// /// 计算复利 盘中 /// @@ -1341,85 +1375,36 @@ namespace YLErp.Modules.SwapModule ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, decimal resetCarryInterest = 0m) { var startDate = position.PosiStartDate; - decimal interestProfitSum = 0; - decimal TdInterestPrincipal = 0; - decimal interest = interestProfitSum ; - decimal tdinterest = interestProfitSum ; int interestPeriod = position.interest_rest_days ?? 1; - // 复利:只能用要平仓的名义本金从头开始算 - decimal dynomicPrincipal = principal; - decimal tdDynomicPrincipal = dynomicPrincipal; - var calcDays = (endDate - startDate).Days; - double floatRate = Convert.ToDouble(floateRate); - for (int i = 0; i <= calcDays; i++) - { - var accrueDate = startDate.AddDays(i); - // 重置日取价必须在 calcFirst/calcLast 跳过之前完成:calcLast=false(不算尾) 只应跳过计息, - // 不应跳过重置日的 FR007 取价。否则平仓日=重置日时会沿用旧周期利率, - // 且 flowEvent.FloatRate 落库为旧值,传染后续 EOD(GLMS-JIATT-20260805 根因)。 - if (accrueDate >= startDate && i % interestPeriod == 0 - && !string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) - { - var fixingDate = IndexFixerBase.GetFixingDate(accrueDate, position.interest_rule); - if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) - { - if (fixing != 0m) floatRate = Convert.ToDouble(fixing); - } - else - { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); - } - } - if (accrueDate >= startDate) - { - if (i % interestPeriod == 0) - { - // 每个重置节点 计息基数 = 前日本金 + 本期利息 - // resetCarryInterest 是上一日终待实现按本次平仓比例分摊后的存量, - // 只能在 endDate 恰好是当前复利重置日时并入本金。历史重置点必须使用 - // 重放到当时的 interest,否则会把上一日终存量反复注入历史本金, - // 例如 0007 的 5/11 部分平仓会由 84,090.95 被多算为 84,114.88。 - var interestToReset = i > 0 && accrueDate == endDate && resetCarryInterest != 0m - ? resetCarryInterest - : interest; - dynomicPrincipal = principal + interestToReset; - tdDynomicPrincipal = principal + interestToReset; - flowEvent.InterestPrincipal = tdDynomicPrincipal; - TdInterestPrincipal = tdDynomicPrincipal; - } - else - { - // 复利非重置日:利息不并入本金,不用closePercent缩放(principal已反映平仓比例) - flowEvent.InterestPrincipal = tdDynomicPrincipal; - TdInterestPrincipal = tdDynomicPrincipal; - } - } - if (!calcFirst && accrueDate == startDate) continue; // 首日不算头 - if (!calcLast && accrueDate == endDate) continue; // 到期日不算尾(只跳过计息,重置本金已在上方完成) - if (accrueDate >= startDate) - { - flowEvent.FloatRate = Convert.ToDecimal(floatRate); - var interest1 = flowEvent.InterestPrincipal * (flowEvent.InterestRate + Convert.ToDecimal(floatRate)); - var tdinterest1 = TdInterestPrincipal * (flowEvent.InterestRate + Convert.ToDecimal(floatRate)); - if (position.IsAnnualized) - { - interest1 /= annualDays; - tdinterest1 /= annualDays; - } - interest += interest1; - tdinterest += tdinterest1; - } - } - // 兜底:若循环因 calcLast 跳过最后一天(重置日=平仓日),flowEvent.FloatRate 不会被循环内赋值, - // 用最终 floatRate 兜底,确保落库的 FloatRate 反映最后一个重置日的利率(GLMS-JIATT-20260805)。 - flowEvent.FloatRate = Convert.ToDecimal(floatRate); - // 复利从头重放得到的是"假设从未结出"的整段总利息,需扣除历史已通过互换结出的利息, - // 否则已结部分会重复计息(类比分红 PosiDividendSum = totalToDate − RealizedDividend)。 - // consumedInterest is full-position absolute interest; scale it to this close portion. - interest -= consumedInterest * closePercent; - tdinterest -= consumedInterest * closePercent; - InterestAmount = Math.Round(interest, InterestCalculationPrecision, MidpointRounding.AwayFromZero); - TdInterestAmount = Math.Round(tdinterest, InterestCalculationPrecision, MidpointRounding.AwayFromZero); + + // 分段取率:复利全程重放,每个重置日(含 startDate)取 FR007(fetchAfterDate=null)。 + var (segmentRates, currentFloat) = BuildSegmentRates( + startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate, + fetchAfterDate: null); + + // 纯函数复利计息:分段重置日并本金 + resetCarryInterest + 扣 consumedInterest + var interestTrace = new AccrualTrace(); + var result = FundingLegAccrual.AccrueCompoundPeriod( + notional: principal, + segmentRates: segmentRates, + startDate: startDate, + endDate: endDate, + boundary: AccrualBoundary.Of(calcFirst, calcLast), + annualDays: annualDays, + isAnnualized: position.IsAnnualized, + resetCarryInterest: resetCarryInterest, + realizedInterest: consumedInterest, + unwindFraction: closePercent, + finalBasis: out var finalBasis, + trace: interestTrace); + SwapCalcTrace.Write(interestTrace); + + // flowEvent 副作用:FloatRate=末段浮动利率;InterestPrincipal=复利终期本金(最后一次并本金后的基数)。 + flowEvent.FloatRate = currentFloat; + flowEvent.InterestPrincipal = finalBasis; + + InterestAmount = result.Accrued; + TdInterestAmount = result.AccruedToday; } /// @@ -1428,59 +1413,39 @@ namespace YLErp.Modules.SwapModule public void CalcDailySimpleInterest(eod_swap_position preEodPosition, DateTime endDate, swap_position position, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, bool needPrice, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m) { var startDate = position.PosiStartDate; - decimal interestProfitSum = preEodPosition.InterestProfitSum; - var TdInterestPrincipal = preEodPosition.TdInterestPrincipal; - decimal interest = interestProfitSum * closePercent; - decimal tdinterest = interestProfitSum * closePercent; int interestPeriod = position.interest_rest_days ?? 1; - // 单利:可用上一日计息基数 - decimal dynomicPrincipal = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv; - decimal tdDynomicPrincipal = dynomicPrincipal; - var calcDays = (endDate - startDate).Days; - double floatRate = Convert.ToDouble(floateRate); - for (int i = 0; i <= calcDays; i++) - { - var accrueDate = startDate.AddDays(i); - if (!calcFirst && accrueDate == startDate) continue; // 首日不算头 - if (!calcLast && accrueDate == endDate) continue; // 到期日不算尾 - if (accrueDate > preEodPosition.ValueDate) - { - // 重置日重新获取该段浮动利率;非重置日沿用上一段利率。 - // 两分支唯一差异即"是否重取利率",本金口径(只缩放一次)完全一致, - // 合并后消除复制粘贴导致的 closePercent^N 类 bug(原非重置日分支多了一行 - // tdDynomicPrincipal = flowEvent.InterestPrincipal 使本金累积乘 closePercent^N)。 - if (i % interestPeriod == 0 && !string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) - { - var fixingDate = IndexFixerBase.GetFixingDate(accrueDate, position.interest_rule); - if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing)) - { - if (fixing != 0m) floatRate = Convert.ToDouble(fixing); - } - else - { - throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格"); - } - } - // 显示本金 = 计息基数 × closePercent(只缩放一次,与日终 ByEod 口径一致); - // 计息基数(tdDynomicPrincipal)逐日恒定、不缩放(单利特征)。 - flowEvent.InterestPrincipal = tdDynomicPrincipal * closePercent; - TdInterestPrincipal = tdDynomicPrincipal; + // orginPv 是路径相关参考本金(资金腿=上一日终浮动端名义本金;保证金腿=上一日终保证金余额)。 + // 单利差分:accrualBasis 全程恒定 = 昨日终滚动基数 + 当日名义本金 - 参考本金。 + var accrualBasis = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv; - flowEvent.FloatRate = Convert.ToDecimal(floatRate); - var interest1 = flowEvent.InterestPrincipal * (flowEvent.InterestRate + Convert.ToDecimal(floatRate)); - var tdinterest1 = TdInterestPrincipal * (flowEvent.InterestRate + Convert.ToDecimal(floatRate)); - if (position.IsAnnualized) - { - interest1 /= annualDays; - tdinterest1 /= annualDays; - } - interest += interest1; - tdinterest += tdinterest1; - } - } - InterestAmount = Math.Round(interest, InterestCalculationPrecision, MidpointRounding.AwayFromZero); - TdInterestAmount = Math.Round(tdinterest, InterestCalculationPrecision, MidpointRounding.AwayFromZero); + // 分段取率:仅 ValueDate 之后的重置日才取 FR007(fetchAfterDate=ValueDate)。 + var (segmentRates, currentFloat) = BuildSegmentRates( + startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate, + fetchAfterDate: preEodPosition.ValueDate); + + // 纯函数计息:Accrued=缩放累计(InterestAmount),AccruedToday=未缩放累计(TdInterestAmount) + var interestTrace = new AccrualTrace(); + var result = FundingLegAccrual.AccrueSimplePeriod( + priorAccrued: preEodPosition.InterestProfitSum * closePercent, + notional: accrualBasis, + unwindFraction: closePercent, + segmentRates: segmentRates, + startDate: startDate, + endDate: endDate, + priorValueDate: preEodPosition.ValueDate, + boundary: AccrualBoundary.Of(calcFirst, calcLast), + annualDays: annualDays, + isAnnualized: position.IsAnnualized, + trace: interestTrace); + SwapCalcTrace.Write(interestTrace); + + // flowEvent 副作用(下游 EOD 用 InterestPrincipal 播种次日 TdInterestPrincipal) + flowEvent.InterestPrincipal = accrualBasis * closePercent; + flowEvent.FloatRate = currentFloat; + + InterestAmount = result.Accrued; + TdInterestAmount = result.AccruedToday; } ///