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;