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)); + } }