diff --git a/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs b/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs new file mode 100644 index 00000000..d2f56e52 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs @@ -0,0 +1,250 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 复利 consumedInterest 扣除 - 合成单元测试 + /// ============================================================================ + /// 验证 c6adb3bb 的修复:复利路径平仓时,扣除历史已通过互换结出的利息。 + /// + /// 核心场景: + /// 一笔复利交易,N天后做了互换结算(已结N天利息),之后再平仓。 + /// 平仓默认值应 = 从头算的全程利息 - 已结利息(consumedInterest)。 + /// 如果不扣(bug),平仓默认值 = 全程利息(偏大)。 + /// 如果多扣(之前单利的错误),平仓默认值 = 0或负(偏小)。 + /// + /// 模仿 GetInterestsUnitTest_T0 的 StubSwapDealService 模式。 + /// ============================================================================ + [TestClass] + public class ConsumedInterestScenarioTest + { + #region 常量 + + private const decimal Principal = 1000m; + private const decimal FixedRate = 0.0025m; // 加点利率 + private const double FloatRate = 0.001; // FR007 + private const decimal TotalRate = FixedRate + (decimal)FloatRate; // 综合年化利率 + private const int AnnualDays = 365; + private const int ResetPeriod = 3; + private static readonly DateTime StartDate = new(2026, 4, 27); + private static readonly DateTime ExerciseDate = new(2027, 4, 27); + + #endregion + + #region Stub:内存 SwapDealService + consumedInterest 注入 + + /// + /// 继承 SwapDealService,override 两个虚方法: + /// - TryGetFloatRate:返回固定浮动利率(不连库) + /// - GetConsumedInterest:返回注入的历史已结利息(不连库) + /// + private sealed class StubSwapDealService : SwapDealService + { + private readonly double _floatRate; + private readonly decimal _consumedInterest; + + public StubSwapDealService(OptUserInfo optUser, double floatRate, decimal consumedInterest) + : base(optUser) + { + _floatRate = floatRate; + _consumedInterest = consumedInterest; + } + + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { + rate = _floatRate; + return true; // 始终返回固定浮动利率 + } + + public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) + { + return _consumedInterest; // 返回注入值 + } + } + + #endregion + + #region 数据构建 + + private static trade CreateTrade() + { + return new trade + { + id = 1, TradeNumber = "UT-CONSUMED-001", ClientId = 999998, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid", + StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY", + trade_extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "10", // 算头不算尾 + SettlementRules = 0 + }) + } + }; + } + + private static swap_position CreateCompoundPosition() + { + return new swap_position + { + id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价, + InterestRateDefault = FixedRate, InterestPrincipalFix = Principal, + PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate, + IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利, + IsAnnualized = true, interest_rest_days = ResetPeriod, interest_rule = 0, + FloatRateUnderlyingCode = "FR007", + InterestSwapInterval = JsonConvert.SerializeObject(new List + { + new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 } + }) + }; + } + + /// 调用 GetInterests 获取复利利息(统一调用入口,settment:false走盘中平仓路径) + private static swap_flow_event CalcCompoundUnwind(StubSwapDealService service, DateTime unwindDate) + { + var td = CreateTrade(); + var position = CreateCompoundPosition(); + var interests = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate, + new List(), new List { position }, + Principal, Principal, Principal, Principal, 1m, + (int)SwapEventTypeEnum.平仓, false, false, Principal, Principal, + add: false, settment: false, newCalcLast: false); + Assert.AreEqual(1, interests.Count); + return interests[0]; + } + + private static StubSwapDealService CreateService(decimal consumedInterest) + { + return new StubSwapDealService( + new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest), + FloatRate, consumedInterest); + } + + private static void AssertDecimal(decimal expected, decimal actual, string message = "") + { + var tolerance = 1m / (decimal)Math.Pow(10, ConsGlobal.PriceRound - 2); + Assert.IsTrue(Math.Abs(expected - actual) <= tolerance, + $"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}"); + } + + #endregion + + // ================================================================ + // 场景1:基线——无历史互换(consumedInterest=0),拿到全程复利利息 + // ================================================================ + + /// + /// [CI_001] 无历史互换结清,复利平仓利息基线 + /// --------------------------------------------------------------- + /// consumedInterest=0,平仓利息=从头算的全程复利利息。 + /// 此值作为后续场景的参照基线(避免独立复利计算的精度匹配问题)。 + /// --------------------------------------------------------------- + /// + [TestMethod] + public void CI_001_无历史互换平仓利息基线() + { + var unwindDate = StartDate.AddDays(10); // 4/27+10=5/7,算头不算尾约9天 + var service = CreateService(consumedInterest: 0m); + var result = CalcCompoundUnwind(service, unwindDate); + + Assert.IsTrue(result.InterestAmount > 0, "无互换时复利利息应>0"); + Console.WriteLine($"基线(consumedInterest=0): InterestAmount={result.InterestAmount:F6}"); + } + + // ================================================================ + // 场景2:consumedInterest>0 → 平仓利息=基线-consumedInterest + // ================================================================ + + /// + /// [CI_002] 注入consumedInterest后,平仓利息应=基线-consumedInterest + /// --------------------------------------------------------------- + /// 用相同参数但注入不同的consumedInterest,验证: + /// 利息(有consumed) = 利息(无consumed) - consumedInterest + /// 这是验证cs:793 `interest -= consumedInterest` 的直接方式。 + /// --------------------------------------------------------------- + /// + [TestMethod] + public void CI_002_consumedInterest正确扣除() + { + var unwindDate = StartDate.AddDays(10); + + // 基线:consumedInterest=0 + var baselineResult = CalcCompoundUnwind(CreateService(0m), unwindDate); + decimal baseline = baselineResult.InterestAmount; + + // 注入consumedInterest=基线的50% + decimal consumed = baseline * 0.5m; + var consumedResult = CalcCompoundUnwind(CreateService(consumed), unwindDate); + + // 期望 = 基线 - consumed + decimal expected = baseline - consumed; + AssertDecimal(expected, consumedResult.InterestAmount, + $"平仓利息应=基线({baseline:F6})-consumed({consumed:F6})={expected:F6}"); + Console.WriteLine($"基线={baseline:F6}, consumed={consumed:F6}"); + Console.WriteLine($"平仓利息={consumedResult.InterestAmount:F6}, 期望={expected:F6} ✅"); + } + + // ================================================================ + // 场景3:守恒——consumed + 平仓利息 = 基线 + // ================================================================ + + /// + /// [CI_003] 守恒:consumedInterest + 平仓利息(扣后) = 基线(无consumed) + /// --------------------------------------------------------------- + /// 注入任意consumedInterest,验证 consumed + 利息 = 基线。 + /// 如果扣多了(守恒不成立→合计<基线)或没扣(合计>基线),测试失败。 + /// --------------------------------------------------------------- + /// + [TestMethod] + public void CI_003_守恒consumed加平仓等于基线() + { + var unwindDate = StartDate.AddDays(10); + decimal baseline = CalcCompoundUnwind(CreateService(0m), unwindDate).InterestAmount; + + // 注入不同的consumedInterest验证守恒 + decimal[] testConsumed = { baseline * 0.3m, baseline * 0.5m, baseline * 0.8m }; + foreach (var consumed in testConsumed) + { + var result = CalcCompoundUnwind(CreateService(consumed), unwindDate); + decimal actual = consumed + result.InterestAmount; + AssertDecimal(baseline, actual, + $"守恒: consumed({consumed:F6}) + 利息({result.InterestAmount:F6}) = {actual:F6} 应=基线({baseline:F6})"); + Console.WriteLine($"consumed={consumed:F6} + 利息={result.InterestAmount:F6} = {actual:F6} = 基线{baseline:F6} ✅"); + } + } + + // ================================================================ + // 场景4:consumedInterest=全部基线 → 平仓利息≈0,不为负 + // ================================================================ + + /// + /// [CI_004] 全部利息已结清(consumedInterest=基线),再平仓利息应≈0 + /// --------------------------------------------------------------- + /// 验证不会扣过头变成负数(之前单利双重扣减的错误)。 + /// 复利从头算全程 - 全程consumed = 0,应精确归零或微小正值。 + /// --------------------------------------------------------------- + /// + [TestMethod] + public void CI_004_全部已结再平仓利息不为负() + { + var unwindDate = StartDate.AddDays(10); + decimal baseline = CalcCompoundUnwind(CreateService(0m), unwindDate).InterestAmount; + + // consumedInterest=全部基线 + var result = CalcCompoundUnwind(CreateService(baseline), unwindDate); + + Console.WriteLine($"基线={baseline:F6}, consumed={baseline:F6}, 平仓利息={result.InterestAmount:F6}"); + Assert.IsTrue(result.InterestAmount >= -0.01m, + $"全部已结再平仓利息应≈0(实际={result.InterestAmount:F6}),不应为负"); + Console.WriteLine($"全部已结平仓≈0({result.InterestAmount:F6})✅"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 1f357ded..c79a30a5 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -347,7 +347,7 @@ namespace YLErp.Modules.SwapModule /// 利息腿id /// 结算日(不含,仅汇总此日之前的历史已结利息;当日事件由 closeList 去重逻辑单独处理) /// 历史已结利息累计金额(绝对值) - public decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) + public virtual decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) { List swapEventTypes = new List() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; var consumed = DbContext.swap_flow_event