diff --git a/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs b/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs new file mode 100644 index 00000000..ccb578be --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/Accrual/CompoundEodShadowTest.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using YLErp; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.SwapModule; +using YLErp.Modules.SwapModule.Accrual; +using YLErp.Derivatives.Interest; +using YLErp.Core.Interest; + +namespace UnitTestProject.Modules.SwapModule.Accrual +{ + /// + /// 影子测试:CalcDailyCompoundInterestByEod(旧逐日循环)vs FundingLegAccrual.AccrueCompoundEod(新纯函数)。 + /// 构造同一组参数,两套实现并行跑,断言结果一致(到分)。 + /// + [TestClass] + public class CompoundEodShadowTest + { + private const decimal Notional = 100_000_000m; + private const decimal FixedRate = 0.03m; + private const int AnnualDays = 365; + private static readonly DateTime TradeDate = new(2026, 4, 21); + private static readonly DateTime EodDate = new(2026, 4, 28); // 第7天=重置日 + + private static trade CreateTrade() + { + return new trade + { + id = 1, TradeNumber = "UT-SHADOW", ClientId = 999998, + TradeType = "收益互换", TradeDate = TradeDate, StartDate = TradeDate, + ExerciseDate = TradeDate.AddYears(1), TradeStatus = "确认成交", ValidState = "Valid", + trade_extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, InterestCalcMode = "11", SettlementRules = 0 + }) + } + }; + } + + private static swap_position CreatePosition(int interestMode, int interestType, int resetDays) + { + return new swap_position + { + id = 1001, SwapTradeId = 1, + PosiDirection = 0, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = interestMode, + InterestRateDefault = FixedRate, + InterestPrincipalFix = Notional, + PosiStartDate = TradeDate, + PosiMatuirityDate = TradeDate.AddYears(1), + IsInitial = true, Invalid = false, + InterestType = interestType, + IsAnnualized = true, + interest_rest_days = resetDays, + interest_rule = 0, + FloatRateUnderlyingCode = null, + InterestSwapInterval = "[]" + }; + } + + private static eod_swap_position CreatePreEod(decimal tdPrincipal, decimal unrealized) + { + return new eod_swap_position + { + id = 1, SwapTradeId = 1, PositionId = 1001, + ValueDate = EodDate.AddDays(-1), + TdInterestPrincipal = tdPrincipal, + InterestProfitSum = unrealized, + PosiNotionalValue = Notional, + FloatRate = 0m + }; + } + + /// + /// 重置日场景:EOD 恰为重置日(7天周期,第7天)。 + /// + [TestMethod] + public void 影子_重置日_旧新一致() + { + var position = CreatePosition((int)InterestModeEnum.合约名义本金规模, (int)InterestTypeEnum.复利, 7); + var preEod = CreatePreEod(Notional, 50_000m); + var flowEvent = new swap_flow_event { InterestRate = FixedRate }; + + // 旧方法 + decimal oldInterest = 0, oldTd = 0; + var svc = new StubSvc(); + svc.CalcDailyCompoundInterestByEod(preEod, EodDate, TradeDate, position, + Notional, Notional, flowEvent, AnnualDays, false, 0m, 1m, Notional, + ref oldInterest, ref oldTd); + + // 新方法 + var rate = FundingLegRate.Fixed(FixedRate); + var policy = new AccrualPolicy(AccrualBoundary.Both, true, 7, AnnualDays, true); + var remainingPercent = Math.Max(0m, Math.Min(1m, Notional / Notional)); + var result = FundingLegAccrual.AccrueCompoundEod( + 50_000m, Notional, Notional, 1m, Notional, rate, policy, + isResetDay: true, remainingPercent, EodDate); + + Console.WriteLine($"重置日: 旧 InterestAmount={oldInterest} Td={oldTd}"); + Console.WriteLine($"重置日: 新 Accrued={result.Accrued} AccruedToday={result.AccruedToday}"); + Assert.AreEqual((double)oldInterest, (double)result.Accrued, 0.01, "InterestAmount 一致"); + Assert.AreEqual((double)oldTd, (double)result.AccruedToday, 0.01, "TdInterestAmount 一致"); + } + + /// + /// 非重置日场景:第3天(非7的倍数)。 + /// + [TestMethod] + public void 影子_非重置日_旧新一致() + { + var nonResetDate = new DateTime(2026, 4, 24); // 第3天 + var position = CreatePosition((int)InterestModeEnum.合约名义本金规模, (int)InterestTypeEnum.复利, 7); + var preEod = CreatePreEod(Notional, 30_000m); + preEod.ValueDate = nonResetDate.AddDays(-1); + var flowEvent = new swap_flow_event { InterestRate = FixedRate }; + + // 旧方法 + decimal oldInterest = 0, oldTd = 0; + var svc = new StubSvc(); + svc.CalcDailyCompoundInterestByEod(preEod, nonResetDate, TradeDate, position, + Notional, Notional, flowEvent, AnnualDays, false, 0m, 1m, Notional, + ref oldInterest, ref oldTd); + + // 新方法 + var rate = FundingLegRate.Fixed(FixedRate); + var policy = new AccrualPolicy(AccrualBoundary.Both, true, 7, AnnualDays, true); + var result = FundingLegAccrual.AccrueCompoundEod( + 30_000m, Notional, Notional, 1m, Notional, rate, policy, + isResetDay: false, 0m, nonResetDate); + + Console.WriteLine($"非重置日: 旧 InterestAmount={oldInterest} Td={oldTd}"); + Console.WriteLine($"非重置日: 新 Accrued={result.Accrued} AccruedToday={result.AccruedToday}"); + Assert.AreEqual((double)oldInterest, (double)result.Accrued, 0.01, "InterestAmount 一致"); + Assert.AreEqual((double)oldTd, (double)result.AccruedToday, 0.01, "TdInterestAmount 一致"); + } + + private sealed class StubSvc : SwapDealService + { + public StubSvc() : base(new OptUserInfo(0, nameof(CompoundEodShadowTest), OptUserFrom.UnitTest)) { } + public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m; + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs b/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs index 593a1500..9e2e6da5 100644 --- a/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs +++ b/YLErpDAL/Modules/SwapModule/Accrual/FundingLegAccrual.cs @@ -9,7 +9,7 @@ namespace YLErp.Modules.SwapModule.Accrual; /// /// 职责边界(与 SwapInterest 原语、SwapDealService 适配器三者正交): /// -/// 本类:持有已解析的 ,执行单利日终计息纯函数。 +/// 本类:持有已解析的 ,执行单利/复利日终计息纯函数。 /// SwapInterest:原子 "本金×利率×天数/年化" 纯函数,无状态。 /// SwapDealService:负责 DB 读、取率、swap_flow_event 构造与落库(IO)。 /// @@ -18,27 +18,7 @@ public static class FundingLegAccrual { /// /// 单利日终计息(纯函数,替换 SwapDealService.CalcDailySimpleInterestByEod 的"纯数学"部分)。 - /// - /// 口径与旧实现逐字对齐(仅命名 DDD 化): - /// - /// 计息基数 baseTdInterestPrincipal = priorAccrualPrincipal + positionPrincipal − originalPv; - /// 当日利息 = baseTdInterestPrincipal × closeRatio × rate.AllInRate,年化则再 ÷ AnnualDays; - /// 累计未实现 = priorUnrealized + 当日利息;末位按资金腿精度 舍入。 - /// - /// - /// 取率与重置日重取浮动利率由适配器(CalcDailySimpleInterestByEod)负责,并封装为 传入; - /// daycount 语义(年化 / 年化天数)由 提供。本方法保持纯函数、可独立单测,不连库、不取价。 /// - /// 上一日日终累计未实现利息(preEod.InterestProfitSum)。 - /// 上一日日终计息本金(preEod.TdInterestPrincipal)。 - /// 存量名义本金(posiPrincipal)。 - /// 平仓比例(closePercent,EOD 恒为 1)。 - /// 原始名义本金(orginPv),用于保证金腿差分基数。 - /// 当日生效利率(已由适配器按腿型封装:固定腿=FixedRate,浮动腿=Spread+IndexFixing)。 - /// 计息政策(daycount:是否年化 / 年化天数)。 - /// 本次日终计息对应的日期(用于 trace 标注"哪一天")。 - /// 可选追踪收集器;传 null 时行为与旧版完全一致(纯计算、无副作用)。 - /// :Accrued=累计未实现(对应 InterestAmount),AccruedToday=当日利息(对应 TdInterestAmount)。 public static InterestResult AccrueSimpleEod( decimal priorUnrealized, decimal priorAccrualPrincipal, @@ -67,7 +47,55 @@ public static class FundingLegAccrual Math.Round(totalUnrealized, SwapInterest.FundingLegPrecision, MidpointRounding.AwayFromZero), Math.Round(tdInterest, SwapInterest.FundingLegPrecision, MidpointRounding.AwayFromZero)); - // 单日追踪:当日利率 / 计息基数 / 当日利息 / 累计未实现。纯函数只产出收集器,落盘由适配器负责。 + trace?.Day(0, eodDate, combinedRate, baseInterestPrincipal, dayInterest, totalUnrealized); + trace?.MarkEnd(result.Accrued, result.AccruedToday); + return result; + } + + /// + /// 复利日终计息(纯函数,替换 CalcDailyCompoundInterestByEod 的"纯数学"部分)。 + /// + /// EOD 只算一天,按当日是否为重置日分两条路径: + /// - 重置日(isResetDay=true):计息本金 = positionPrincipal + priorUnrealized × remainingPercent(利息并入本金) + /// - 非重置日:计息本金 = priorAccrualPrincipal + positionPrincipal - originalPv(差分) + /// + /// remainingPercent = principal / posiPrincipal(由调用方算好传入,对应旧代码 :1505-1508)。 + /// + public static InterestResult AccrueCompoundEod( + decimal priorUnrealized, + decimal priorAccrualPrincipal, + decimal positionPrincipal, + decimal closeRatio, + decimal originalPv, + FundingLegRate rate, + AccrualPolicy policy, + bool isResetDay, + decimal remainingPercent, + DateTime eodDate, + AccrualTrace? trace = null) + { + // 重置日:利息并入本金;非重置日:差分本金 + var baseTdInterestPrincipal = isResetDay + ? positionPrincipal + priorUnrealized * remainingPercent + : priorAccrualPrincipal + positionPrincipal - originalPv; + var baseInterestPrincipal = baseTdInterestPrincipal * closeRatio; + + var combinedRate = rate.AllInRate; + var dayInterest = baseInterestPrincipal * combinedRate; + var tdInterest = baseTdInterestPrincipal * combinedRate; + if (policy.IsAnnualized) + { + dayInterest /= policy.AnnualDays; + tdInterest /= policy.AnnualDays; + } + + // 旧代码(两分支相同):InterestAmount = priorUnrealized × closePercent + dayInterest + // TdInterestAmount = dayInterest(不含 priorUnrealized) + var totalUnrealized = priorUnrealized * closeRatio + dayInterest; + var result = new InterestResult( + Math.Round(totalUnrealized, SwapInterest.FundingLegPrecision, MidpointRounding.AwayFromZero), + Math.Round(tdInterest, SwapInterest.FundingLegPrecision, MidpointRounding.AwayFromZero)); + trace?.Day(0, eodDate, combinedRate, baseInterestPrincipal, dayInterest, totalUnrealized); trace?.MarkEnd(result.Accrued, result.AccruedToday); return result;