From 431ac9b502707ffdbaabadd46af043819dac5246 Mon Sep 17 00:00:00 2001 From: hjhan Date: Wed, 12 Aug 2026 09:48:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(accrual):=20=E6=96=B0=E5=A2=9E=20AccrueCom?= =?UTF-8?q?poundPeriod=20+=20=E5=BD=B1=E5=AD=90=E6=B5=8B=E8=AF=95(3?= =?UTF-8?q?=E4=B8=AA=E5=85=A8=E8=BF=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第3步迁移: CalcDailyCompoundInterest(盘中复利多日)。 新增纯函数 AccrueCompoundPeriod: - 从 PosiStartDate 到 endDate 全程重放 - 每个重置日并本金: principal + replayed interest - 末日(endDate)为重置日且 resetCarryInterest!=0 时用存量替代 - consumedInterest × closePercent 在末尾扣除 - 分段模型替代逐日循环, 段内本金恒定+利率恒定 影子测试(3个,全过): - 固定利率全平+末日重置日: 旧新一致 - 部分平仓30%+consumedInterest扣除: 旧新一致 - 算头算尾(calcMode=11): 旧新一致 清理: 删除 SwapInterestAccrueCompoundInArrearsTest(半成品残留, 依赖已回退的 SwapInterest 签名)。 修复: SwapInterestAccrueCompoundInArrearsTest 缺 using(已随文件删除)。 验证: 编译0错误, 全量520测试7失败(基线一致)。 4个CalcDaily*方法的新架构纯函数全部就绪, 旧方法暂保留对比。 --- .../Accrual/CompoundPeriodShadowTest.cs | 187 ++++++++++++++++++ .../SwapModule/Accrual/FundingLegAccrual.cs | 88 +++++++++ 2 files changed, 275 insertions(+) create mode 100644 UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs diff --git a/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs new file mode 100644 index 00000000..bcba5199 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Accrual/CompoundPeriodShadowTest.cs @@ -0,0 +1,187 @@ +using Newtonsoft.Json; +using YLErp; +using YLErp.Derivatives.Interest; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Accrual; + +namespace UnitTestProject.Modules.SwapModule.Accrual +{ + /// + /// 影子测试:CalcDailyCompoundInterest(旧逐日循环)vs FundingLegAccrual.AccrueCompoundPeriod(新分段纯函数)。 + /// + [TestClass] + public class CompoundPeriodShadowTest + { + private const decimal Notional = 100_000_000m; + private const decimal Spread = 0.0025m; + private const int AnnualDays = 365; + private static readonly DateTime StartDate = new(2026, 4, 21); + private static readonly DateTime EndDate = new(2026, 5, 11); // 21天 = 3×7, 末日是重置日 + + private static trade CreateTrade() + { + return new trade + { + id = 1, TradeNumber = "UT-COMPOUND-SHADOW", ClientId = 999998, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = StartDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid", + trade_extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0 + }) + } + }; + } + + private static swap_position CreatePosition() + { + return new swap_position + { + id = 1001, SwapTradeId = 1, PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.标的期初全价, + InterestRateDefault = Spread, + InterestPrincipalFix = Notional, + PosiStartDate = StartDate, PosiMatuirityDate = StartDate.AddYears(1), + IsInitial = true, Invalid = false, + InterestType = (int)InterestTypeEnum.复利, + IsAnnualized = true, interest_rest_days = 7, interest_rule = 0, + FloatRateUnderlyingCode = null, + InterestSwapInterval = "[]" + }; + } + + private sealed class StubSvc : SwapDealService + { + public StubSvc() : base(new OptUserInfo(0, nameof(CompoundPeriodShadowTest), OptUserFrom.UnitTest)) { } + } + + /// + /// 固定利率(无FR007)算头不算尾,全平。 + /// 重置日 4/28, 5/5, 末日 5/11 恰为重置日(21天=3×7)。 + /// + [TestMethod] + public void 影子_固定利率_全平_末日重置日_旧新一致() + { + var position = CreatePosition(); + var flowEvent = new swap_flow_event { InterestRate = Spread }; + var preEod = new eod_swap_position { id = 0 }; + + // 旧方法 + decimal oldI = 0, oldTd = 0; + var svc = new StubSvc(); + svc.CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent, + AnnualDays, false, 0m, 1m, Notional, true, false, + ref oldI, ref oldTd); + + // 新方法:固定利率全段相同,分段点 = PosiStartDate + k×7 + // 段: [4/21,4/28), [4/28,5/5), [5/5,5/11] → 注意旧代码 calcLast=false 不算末日 + var allInRate = Spread; // 无浮动利率 + var segRates = new List<(DateTime, decimal)> + { + (StartDate, allInRate), + (StartDate.AddDays(7), allInRate), + (StartDate.AddDays(14), allInRate), + }; + var result = FundingLegAccrual.AccrueCompoundPeriod( + principal: Notional, + segmentRates: segRates, + startDate: StartDate, + endDate: EndDate, + 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 一致"); + } + + /// + /// 部分平仓 30% + consumedInterest 扣除。 + /// + [TestMethod] + public void 影子_部分平仓30pct_consumedInterest_旧新一致() + { + var position = CreatePosition(); + var flowEvent = new swap_flow_event { InterestRate = Spread }; + var preEod = new eod_swap_position { id = 0 }; + const decimal consumed = 50_000m; + const decimal closePct = 0.3m; + const decimal carry = 0m; // 无历史归档 + + // 旧方法 + decimal oldI = 0, oldTd = 0; + var svc = new StubSvc(); + svc.CalcDailyCompoundInterest(EndDate, position, Notional * closePct, flowEvent, + AnnualDays, false, 0m, closePct, Notional, true, false, + ref oldI, ref oldTd, consumedInterest: consumed, resetCarryInterest: carry); + + // 新方法 + var allInRate = Spread; + var segRates = new List<(DateTime, decimal)> + { + (StartDate, allInRate), + (StartDate.AddDays(7), allInRate), + (StartDate.AddDays(14), allInRate), + }; + var result = FundingLegAccrual.AccrueCompoundPeriod( + principal: Notional * closePct, + segmentRates: segRates, + startDate: StartDate, + endDate: EndDate, + boundary: AccrualBoundary.StartOnly, + annualDays: AnnualDays, + isAnnualized: true, + resetCarryInterest: carry, + consumedInterest: consumed, + closePercent: closePct); + + Console.WriteLine($"旧: I={oldI} Td={oldTd}"); + Console.WriteLine($"新: Accrued={result.Accrued} AccruedToday={result.AccruedToday}"); + Assert.AreEqual((double)oldI, (double)result.Accrued, 0.01, "InterestAmount 一致"); + } + + /// + /// 算头算尾(calcMode="11")对比。 + /// + [TestMethod] + public void 影子_算头算尾_旧新一致() + { + var position = CreatePosition(); + var flowEvent = new swap_flow_event { InterestRate = Spread }; + var preEod = new eod_swap_position { id = 0 }; + + // 旧方法 + decimal oldI = 0, oldTd = 0; + var svc = new StubSvc(); + svc.CalcDailyCompoundInterest(EndDate, position, Notional, flowEvent, + AnnualDays, false, 0m, 1m, Notional, true, true, + ref oldI, ref oldTd); + + // 新方法 + var allInRate = Spread; + var segRates = new List<(DateTime, decimal)> + { + (StartDate, allInRate), + (StartDate.AddDays(7), allInRate), + (StartDate.AddDays(14), allInRate), + }; + var result = FundingLegAccrual.AccrueCompoundPeriod( + principal: Notional, + segmentRates: segRates, + startDate: StartDate, + endDate: EndDate, + boundary: AccrualBoundary.Both, + 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 一致"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs b/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs index 183edd75..bf0c88cc 100644 --- a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs +++ b/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs @@ -171,4 +171,92 @@ public static class FundingLegAccrual Math.Round(interest, precision, MidpointRounding.AwayFromZero), Math.Round(tdInterest, precision, MidpointRounding.AwayFromZero)); } + + /// + /// 复利多日计息(纯函数,替换 CalcDailyCompoundInterest 的"纯数学"部分)。 + /// + /// 复利特征:从 PosiStartDate 到 endDate 全程重放,每个重置日把累计利息并入本金。 + /// 分段模型:每段从重置日到下一个重置日(或 endDate),段内本金恒定。 + /// + /// 关键语义(与旧逐日循环逐字对齐): + /// - 首段(i=0)也算重置日:dynomicPrincipal = principal(从头开始) + /// - 后续重置日:dynomicPrincipal = principal + interest(利息并入本金) + /// - 末日恰为重置日且 resetCarryInterest != 0:用 resetCarryInterest 替代 replayed interest + /// - consumedInterest × closePercent 在最后扣除 + /// - IsAnnualized 控制 dayRate 是否除以 annualDays + /// + /// 本次平仓名义本金(已按 closePercent 缩放)。 + /// 分段利率表:(段起日, all-in利率 = spread + FR007),按日期升序。段起日 = PosiStartDate + k×period。 + /// PosiStartDate。 + /// 平仓日。 + /// 算头算尾。 + /// 年化天数。 + /// 是否年化。 + /// 末日重置时的存量利息(非末日传 0)。 + /// 历史已结利息(全程绝对值)。 + /// 平仓比例。 + public static InterestResult AccrueCompoundPeriod( + decimal principal, + IReadOnlyList<(DateTime StartDate, decimal Rate)> segmentRates, + DateTime startDate, + DateTime endDate, + AccrualBoundary boundary, + int annualDays, + bool isAnnualized, + decimal resetCarryInterest = 0m, + decimal consumedInterest = 0m, + decimal closePercent = 1m) + { + var precision = SwapInterest.FundingLegPrecision; + decimal dynomicPrincipal = principal; + decimal interest = 0m; + decimal tdinterest = 0m; + + for (int si = 0; si < segmentRates.Count; si++) + { + var (segStart, segRate) = segmentRates[si]; + var segEnd = si < segmentRates.Count - 1 + ? segmentRates[si + 1].StartDate + : endDate; + + // 重置日并本金:首段(si=0)用 principal,后续用 principal + replayed interest + if (si == 0) + { + dynomicPrincipal = principal; + } + else + { + dynomicPrincipal = principal + interest; + } + + // 末日(endDate)恰为重置日且 resetCarryInterest 非零:用存量替代 replayed + if (segEnd == endDate && si > 0 && resetCarryInterest != 0m) + { + dynomicPrincipal = principal + resetCarryInterest; + } + + // 段内算头算尾:首段用 boundary.IncludeStart,后续段不算头 + var segIncludeStart = (si == 0) ? boundary.IncludeStart : false; + var segIncludeEnd = (segEnd == endDate) ? boundary.IncludeEnd : true; + var segBoundary = AccrualBoundary.Of(segIncludeStart, segIncludeEnd); + + var days = SwapInterest.AccrualDays(segStart, segEnd, segBoundary); + if (days <= 0) continue; + + var dailyRate = isAnnualized ? segRate / annualDays : segRate; + var daily = Math.Round(dynomicPrincipal * dailyRate, precision, MidpointRounding.AwayFromZero); + var segInterest = Math.Round(daily * days, precision, MidpointRounding.AwayFromZero); + + interest += segInterest; + tdinterest += segInterest; + } + + // 扣除历史已结利息 + interest -= consumedInterest * closePercent; + tdinterest -= consumedInterest * closePercent; + + return new InterestResult( + Math.Round(interest, precision, MidpointRounding.AwayFromZero), + Math.Round(tdinterest, precision, MidpointRounding.AwayFromZero)); + } }