diff --git a/UnitTestProject/Modules/SwapModule/MultiUnwindDividendConservationTest.cs b/UnitTestProject/Modules/SwapModule/MultiUnwindDividendConservationTest.cs new file mode 100644 index 00000000..9dcb190a --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/MultiUnwindDividendConservationTest.cs @@ -0,0 +1,326 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 期间多次部分平仓 + 中间穿插互换 —— PosiDividendSum 递推一致性测试 + /// ============================================================================ + /// 背景:GLMS-20260105-0007 修复(SwapEodPositionService.cs:1627-1636)把 + /// UpdateEodPosition 的 PosiDividendSum 从"从头重算"改为递增模式: + /// PosiDividendSum = 前日 PosiDividendSum + 当日 TdPosiDividend - 当日 TdCloseDividend + /// + /// 该递推公式此前只在 GLMS20260105GoldenTest 的"单日全互换"场景被验证过(持仓恒定、 + /// 1 条互换事件、全部结清)。本测试补齐覆盖空白: + /// - 跨多个结算日的逐日递推一致性 + /// - 部分平仓后,当日新增分红 TdPosiDividend 按【剩余持仓】计算(cs:1614 用 curretEod.PosiQuantity) + /// - 中间穿插互换结算(EventType=3,不扣持仓,但 DividendIn 进入 TdCloseDividend) + /// - 全平后 PosiQuantity==0 走 else 分支 PosiDividendSum=0 + /// - 守恒:累计 RealizedDividend + 末尾待实现 ≈ 全程 TdPosiDividend 总和(round 累积容差内) + /// - 无从头重算的精度漂移 + /// + /// 内存 stub,无数据库依赖,进 CI。 + /// ============================================================================ + [TestClass] + public class MultiUnwindDividendConservationTest + { + // 场景常量:收取方向(directionRatio=+1,数值为正便于手算)、多头、1000 单位持仓。 + // 注:GLMS20260105 真实数据为支付方向(数值为负),但递推公式与方向无关, + // 本测试取收取方向让期望值直观易读。 + private const int SwapTradeId = 9100; + private const long PositionId = 9101; + private const decimal InitialQty = 1000m; + private const decimal DailyRatePerUnit = 0.01m; // 每单位每天票息 0.01,便于手算 + private static readonly DateTime StartDate = new(2026, 1, 5); + + #region Stub + + /// + /// 参考 DealFloatPositionsScenarioTest.StubEodService,关键改进: + /// CalcBondPayment 改为按天数 × 持仓线性函数,使 TdPosiDividend 真实随 + /// "天数 × 剩余持仓"变化——这是验证多日递推守恒的前提。 + /// + private sealed class StubEodService : SwapEodPositionService + { + private readonly decimal _dailyRatePerUnit; + + public StubEodService(decimal dailyRatePerUnit) : base(new OptUserInfo(0, nameof(MultiUnwindDividendConservationTest), OptUserFrom.UnitTest)) + { + _dailyRatePerUnit = dailyRatePerUnit; + } + + // 按天线性付息:dailyRate × 天数 × 持仓 × shortRatio × directionRatio + // 与生产 BondPaymentService 的线性口径一致,便于手算期望值 + protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) + { + int days = Math.Max(0, (int)(toDate - fromDate).TotalDays); + return _dailyRatePerUnit * days * qty * shortRatio * directionRatio; + } + + protected override underlying_manager GetUnderlyingData(string underlyingCode) + => new underlying_manager { ValueAddedTax = 0m }; + + protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp) + { + vobp = 0m; // vobp=0 让 Dv01Helper.CalcDv01 短路返回 0,不触碰 DataCacheProvider + return 1.00m; + } + + protected override void SaveAllChanges() { } + + // 注意:UpdateEodPosition.cs:1645 直接 new EodCurrencyRateService,不走此 seam; + // 但 trade.QuoteCurrency == trade.SettlementCurrency == "CNY" 时, + // EodCurrencyRateService.GetEodCurrencyRate 会在查库前短路返回 Rate=1(cs:268-281) + protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) + => 1.0; + + // 暴露 protected UpdateEodPosition(参考 GLMS20260105GoldenTest.ReplayStubService:244) + public eod_swap_position ExecuteUpdateEodPosition( + swap_position swapPosition, eod_swap_position eod, trade td, + DateTime valueDate, DateTime preSettleDate, List unwindEvents) + { + return UpdateEodPosition(swapPosition, eod, null, td, valueDate, preSettleDate, unwindEvents); + } + + // 暴露 protected CopyEodPosition(无事件日用,与 DealFloatPositions 的真实分派一致) + public eod_swap_position ExecuteCopyEodPosition(eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate) + { + return CopyEodPosition(eod, null, td, valueDate, preSettleDate); + } + } + + #endregion + + #region 数据构建 + + private static trade CreateTrade() + { + return new trade + { + id = SwapTradeId, TradeNumber = "UT-MULTI-UNWIND-001", ClientId = 999999, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = new DateTime(2027, 1, 5), TradeStatus = "确认成交", + ValidState = "Valid", StructureType = "单标的", + QuoteCurrency = "CNY", SettlementCurrency = "CNY", // 同币种:汇率短路 Rate=1,不查库 + OriginalStockEqvNotional = (double)(InitialQty * 1.00m) // 与持仓 × 净价匹配 + }; + } + + private static swap_position CreatePosition() + { + return new swap_position + { + id = PositionId, SwapTradeId = SwapTradeId, + PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "210210.IB", ContractSize = 1m, + PosiQuantity = InitialQty, PosiNotionalValue = InitialQty, + PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m, + PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m, + IsInitial = true, Invalid = false, + PosiTradingFee = 0, PosiTradingFeePending = 0 + }; + } + + /// 首日 eod:ValueDate=StartDate,PosiDividendSum=0 + private static eod_swap_position CreateInitialEod() + { + return new eod_swap_position + { + id = 1, SwapTradeId = SwapTradeId, PositionId = PositionId, + ValueDate = StartDate, PosiQuantity = InitialQty, + PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "210210.IB", ContractSize = 1m, + PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m, + PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m, + PosiDividendSum = 0m, TdPosiDividend = 0m, TdCloseDividend = 0m, + RealizedDividend = 0m, PosiFeePending = 0m, + InterestProfitSum = 0m, Invalid = false + }; + } + + private static swap_flow_event CloseEvent(decimal qty, decimal dividendIn, DateTime eventDate) + { + return new swap_flow_event + { + SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.平仓, + PositionId = PositionId, Quantity = qty, DividendIn = dividendIn, + MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m, + TradingAmount = qty * 1.000m, + UnwindDate = eventDate, EventDate = eventDate, PayDate = eventDate, + DataState = (int)SwapFlowDateStateEnum.完成 + }; + } + + private static swap_flow_event SwapEvent(decimal dividendIn, DateTime eventDate) + { + return new swap_flow_event + { + SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.互换, + PositionId = PositionId, Quantity = 0m, DividendIn = dividendIn, + MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m, + EventDate = eventDate, PayDate = eventDate, + DataState = (int)SwapFlowDateStateEnum.完成 + }; + } + + #endregion + + // ================================================================ + // 主测试:6 个结算日的多次部分平仓 + 中间互换序列 + // ================================================================ + + /// + /// [MU_001] 期间多次部分平仓 + 中间穿插互换 → PosiDividendSum 逐日递推一致、全平归零、守恒 + /// ---------------------------------------------------------------------------- + /// 真实 EOD 分派(DealFloatPositions:496-507):无事件走 CopyEodPosition, + /// 有平仓/互换事件走 UpdateEodPosition。本测试按此分派编排 6 个结算日, + /// 验证两个方法的 PosiDividendSum 递推口径在跨方法、多事件下严格对齐 + /// (这正是 GLMS-20260105-0007 修复的核心:两者口径一致才能消除漂移)。 + /// + /// 序列(trade.StartDate=2026-01-05,每日间隔1天,DailyRatePerUnit=0.01): + /// D1=01-06 无事件(Copy) :增量10(1天×0.01×1000), Sum: 0→10 + /// D2=01-07 部分平仓30% :持仓1000→700, 增量7(按700), 实现3, Sum: 10→14 + /// D3=01-08 无事件(Copy) :增量7(按700), Sum: 14→21 + /// D4=01-09 互换结算 :EventType=3不扣持仓=700, 增量7, 实现10, Sum: 21→18 + /// D5=01-10 无事件(Copy) :增量7(按700), Sum: 18→25 + /// D6=01-11 全平700 :持仓→0, 增量0(全平后不计), Sum: 0(else分支) + /// + [TestMethod] + public void MU_001_多次部分平仓穿插互换_分红递推一致且守恒() + { + var service = new StubEodService(DailyRatePerUnit); + var td = CreateTrade(); + var position = CreatePosition(); + + // 记录全程累积量,供末尾守恒断言 + decimal sumTdPosiDividend = 0m; // 全程新增待实现分红之和 + decimal sumTdCloseDividend = 0m; // 全程已实现分红之和 + var dailyResults = new List<(DateTime date, eod_swap_position eod)>(); + var initialEod = CreateInitialEod(); + + // ---- D1=2026-01-06:无事件 → CopyEodPosition 分支 ---- + var d1 = new DateTime(2026, 1, 6); + var r1 = service.ExecuteCopyEodPosition(initialEod, td, d1, StartDate); + dailyResults.Add((d1, r1)); + sumTdPosiDividend += r1.TdPosiDividend; + // 期望:TdPosiDividend = 0.01 × 1天 × 1000 = 10;PosiDividendSum = 0 + 10 = 10 + AssertDecimalEqual(10m, r1.TdPosiDividend, 0.01m, "D1 TdPosiDividend(1天×0.01×1000)"); + AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum(0+10)"); + Assert.AreEqual(InitialQty, r1.PosiQuantity, "D1 持仓不变"); + AssertCopyIncrementalConsistency(initialEod, r1, "D1"); + + // ---- D2=2026-01-07:部分平仓 30%(300单位)→ UpdateEodPosition 分支 ---- + var d2 = new DateTime(2026, 1, 7); + var d2DividendIn = 3m; // 平仓实现的分红(业务方按平仓比例给定) + var r2 = service.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List { CloseEvent(300m, d2DividendIn, d2) }); + dailyResults.Add((d2, r2)); + sumTdPosiDividend += r2.TdPosiDividend; + sumTdCloseDividend += r2.TdCloseDividend; + Assert.AreEqual(700m, r2.PosiQuantity, "D2 部分平仓后持仓 1000-300=700"); + // 【关键】TdPosiDividend 按【剩余持仓 700】算:0.01 × 1天 × 700 = 7(不是 10) + AssertDecimalEqual(7m, r2.TdPosiDividend, 0.01m, "D2 TdPosiDividend 应按剩余持仓700算(1天×0.01×700)"); + AssertDecimalEqual(d2DividendIn, r2.TdCloseDividend, 0.01m, "D2 TdCloseDividend=平仓DividendIn"); + // 递推:PosiDividendSum = 10 + 7 - 3 = 14 + AssertDecimalEqual(14m, r2.PosiDividendSum, 0.01m, "D2 PosiDividendSum(10+7-3)"); + AssertUpdateIncrementalConsistency(r1, r2, "D2"); + + // ---- D3=2026-01-08:无事件 → CopyEodPosition(继续按 700 累积)---- + var d3 = new DateTime(2026, 1, 8); + var r3 = service.ExecuteCopyEodPosition(r2, td, d3, d2); + dailyResults.Add((d3, r3)); + sumTdPosiDividend += r3.TdPosiDividend; + Assert.AreEqual(700m, r3.PosiQuantity, "D3 持仓不变=700(Copy继承前日)"); + AssertDecimalEqual(7m, r3.TdPosiDividend, 0.01m, "D3 TdPosiDividend(1天×0.01×700)"); + AssertDecimalEqual(21m, r3.PosiDividendSum, 0.01m, "D3 PosiDividendSum(14+7)"); + AssertCopyIncrementalConsistency(r2, r3, "D3"); + + // ---- D4=2026-01-09:互换结算(EventType=3,不扣持仓,DividendIn 进入 TdCloseDividend)→ Update 分支 ---- + var d4 = new DateTime(2026, 1, 9); + var d4DividendIn = 10m; // 互换事件实现的待实现分红 + var r4 = service.ExecuteUpdateEodPosition(position, r3, td, d4, d3, new List { SwapEvent(d4DividendIn, d4) }); + dailyResults.Add((d4, r4)); + sumTdPosiDividend += r4.TdPosiDividend; + sumTdCloseDividend += r4.TdCloseDividend; + Assert.AreEqual(700m, r4.PosiQuantity, "D4 互换事件不扣持仓=700"); + AssertDecimalEqual(7m, r4.TdPosiDividend, 0.01m, "D4 TdPosiDividend(互换不影响增量,1天×0.01×700)"); + AssertDecimalEqual(d4DividendIn, r4.TdCloseDividend, 0.01m, "D4 TdCloseDividend=互换DividendIn"); + // 递推:PosiDividendSum = 21 + 7 - 10 = 18 + AssertDecimalEqual(18m, r4.PosiDividendSum, 0.01m, "D4 PosiDividendSum(21+7-10)"); + AssertUpdateIncrementalConsistency(r3, r4, "D4"); + + // ---- D5=2026-01-10:无事件 → CopyEodPosition ---- + var d5 = new DateTime(2026, 1, 10); + var r5 = service.ExecuteCopyEodPosition(r4, td, d5, d4); + dailyResults.Add((d5, r5)); + sumTdPosiDividend += r5.TdPosiDividend; + Assert.AreEqual(700m, r5.PosiQuantity, "D5 持仓=700"); + AssertDecimalEqual(7m, r5.TdPosiDividend, 0.01m, "D5 TdPosiDividend"); + AssertDecimalEqual(25m, r5.PosiDividendSum, 0.01m, "D5 PosiDividendSum(18+7)"); + AssertCopyIncrementalConsistency(r4, r5, "D5"); + + // ---- D6=2026-01-11:全平 700 → Update 分支 ---- + var d6 = new DateTime(2026, 1, 11); + var d6DividendIn = 25m; // 把剩余待实现全作 DividendIn 实现 + var r6 = service.ExecuteUpdateEodPosition(position, r5, td, d6, d5, new List { CloseEvent(700m, d6DividendIn, d6) }); + dailyResults.Add((d6, r6)); + sumTdPosiDividend += r6.TdPosiDividend; + sumTdCloseDividend += r6.TdCloseDividend; + Assert.AreEqual(0m, r6.PosiQuantity, "D6 全平后持仓=0"); + // 全平走 else 分支:PosiDividendSum=0(不再递推) + AssertDecimalEqual(0m, r6.PosiDividendSum, 0.01m, "D6 全平后 PosiDividendSum=0(else分支)"); + Assert.AreEqual(1, r6.PosiStatus, "D6 PosiStatus=已平"); + + // ================================================================ + // 守恒性断言:全程新增 - 全程实现 = 末尾待实现(容差 0.01 允许 round 累积) + // ================================================================ + decimal endingPending = sumTdPosiDividend - sumTdCloseDividend; + AssertDecimalEqual(r6.PosiDividendSum, endingPending, 0.01m, + $"守恒:末尾 PosiDividendSum({r6.PosiDividendSum}) 应=全程新增({sumTdPosiDividend})-全程实现({sumTdCloseDividend})={endingPending}"); + + Console.WriteLine("=== 多日序列 PosiDividendSum 演变 ==="); + foreach (var (date, eod) in dailyResults) + { + Console.WriteLine($" {date:yyyy-MM-dd}: Qty={eod.PosiQuantity}, TdPosiDividend={eod.TdPosiDividend}, TdCloseDividend={eod.TdCloseDividend}, PosiDividendSum={eod.PosiDividendSum}, RealizedDividend={eod.RealizedDividend}"); + } + Console.WriteLine($"\n守恒检查:Σ新增={sumTdPosiDividend}, Σ实现={sumTdCloseDividend}, 末尾待实现={r6.PosiDividendSum} ✅"); + } + + // ================================================================ + // 辅助断言:逐日递推一致性(持仓>0 时) + // ================================================================ + + /// + /// 验证 UpdateEodPosition 的 PosiDividendSum 严格满足递推公式(cs:1631): + /// PosiDividendSum = prev.PosiDividendSum + result.TdPosiDividend - result.TdCloseDividend + /// (仅持仓>0 时适用;全平时走 else 归零,由调用方单独断言) + /// + private static void AssertUpdateIncrementalConsistency(eod_swap_position prev, eod_swap_position result, string day) + { + if (result.PosiQuantity <= 0) return; // 全平走 else 分支,跳过递推断言 + decimal expected = prev.PosiDividendSum + result.TdPosiDividend - result.TdCloseDividend; + Assert.IsTrue(Math.Abs(expected - result.PosiDividendSum) <= 0.01m, + $"{day} Update递推一致性失败:期望 PosiDividendSum={prev.PosiDividendSum}+{result.TdPosiDividend}-{result.TdCloseDividend}={expected},实际={result.PosiDividendSum}"); + } + + /// + /// 验证 CopyEodPosition 的 PosiDividendSum 严格满足递推公式(cs:1527): + /// PosiDividendSum = Math.Round(prev.PosiDividendSum + result.TdPosiDividend, 2) + /// (无平仓日,TdCloseDividend=0;仅持仓>0 时适用) + /// + private static void AssertCopyIncrementalConsistency(eod_swap_position prev, eod_swap_position result, string day) + { + if (result.PosiQuantity <= 0) return; + decimal expected = Math.Round(prev.PosiDividendSum + result.TdPosiDividend, 2); + Assert.IsTrue(Math.Abs(expected - result.PosiDividendSum) <= 0.01m, + $"{day} Copy递推一致性失败:期望 PosiDividendSum=Round({prev.PosiDividendSum}+{result.TdPosiDividend},2)={expected},实际={result.PosiDividendSum}"); + // Copy 分支当日无实现 + Assert.AreEqual(0m, result.TdCloseDividend, $"{day} Copy分支应无 TdCloseDividend"); + } + + private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "") + { + Assert.IsTrue(Math.Abs(expected - actual) <= tolerance, + $"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}"); + } + } +}