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