using YLErp; using YLErp.DBModels; using YLErp.DBModels.Enums; using YLErp.Modules.EodModule; namespace YLErp.Modules.SwapModule { /// /// 端到端:盘中收益互换(DividendIn 由生产方法 GetPreEodDividendSum 真实算出)→ 保存 → EOD, /// 验证分红【不重复累计】(EOD TdCloseDividend 扣减 DividendIn)且【不丢失】(当日新计进 PosiDividendSum)。 /// /// 与 MultiUnwindDividendConservationTest.MU_001 的区别:MU_001 的互换 DividendIn 是测试喂的常量; /// 本测试的 DividendIn 由生产方法 GetPreEodDividendSum 真实算出(读 EOD 快照),再喂给 EOD—— /// 覆盖"预览算 DividendIn + EOD 扣减"的完整链路(MU_001 的缺口)。 /// [TestClass] public class DividendEodNoDoubleCountTest { private const int SwapTradeId = 9200; private const long PositionId = 9201; private const decimal InitialQty = 1000m; private const decimal RegPer100 = 1.0m; // 每 100 元面值票息 1.0 → qty(1000) 时单期分红 = 1.0×1000/100 = 10 private static readonly DateTime StartDate = new(2026, 1, 5); #region 内存债券付息数据(reg_date 口径,真实生产 GetBondPayments 读取) private const string BondUnderlying = "210210.IB"; private static List BondPayments() => new List { // 登记日 1/6、1/7 各一期;支付日滞后若干日(刻意与登记日不同,验证按 reg_date 而非 pay_date 计提) new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 6), payment_date_pl = new DateTime(2026, 1, 9), payment_date = new DateTime(2026, 1, 9), payment_interest = RegPer100 }, new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 7), payment_date_pl = new DateTime(2026, 1, 10), payment_date = new DateTime(2026, 1, 10), payment_interest = RegPer100 }, }; #endregion #region Stubs /// SwapDealService stub:暴露 GetPreEodDividendSum,注入 EOD 数据(不连库)。 private sealed class DealSvcStub : SwapDealService { private readonly List _eodSwaps; private readonly List _eodPositions; public DealSvcStub(List eodSwaps, List eodPositions) : base(OptUserInfo.UnitTestUser) { _eodSwaps = eodSwaps; _eodPositions = eodPositions; } public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate) => GetPreEodDividendSum(tradeId, positionId, dealDate); protected override IQueryable QueryPreEodSwaps(int tradeId) => _eodSwaps.Where(x => x.SwapTradeId == tradeId).AsQueryable(); protected override eod_swap_position QueryPreEodPosition(int tradeId, long positionId, DateTime valueDate) => _eodPositions.FirstOrDefault(x => x.SwapTradeId == tradeId && x.PositionId == positionId && x.ValueDate == valueDate); } /// 真实 BondPaymentService(reg_date 口径)seam:仅注入内存 BondPayment 数据,票息计算走生产 GetBondPayments+CalcPayment。 private sealed class RealBondPaymentService : BondPaymentService { private readonly List _data; public RealBondPaymentService(List data, OptUserInfo userInfo) : base(userInfo) { _data = data; } protected override IQueryable QueryBondPayments(string underlyingCode) => _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable(); } /// SwapEodPositionService stub:暴露 UpdateEodPosition/CopyEodPosition;CalcBondPayment 桥接真实 BondPaymentService(reg_date 口径,不再用线性假公式)。 private sealed class EodSvcStub : TestableSwapEodPositionService { private readonly List _bondPayments; public EodSvcStub(List bondPayments) : base(nameof(DividendEodNoDoubleCountTest)) { _bondPayments = bondPayments; } protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) { // 桥接真实生产口径:GetBondPayments 按 reg_date 过滤 + CalcPayment 累加(替换原线性假公式 DailyRatePerUnit*days*qty) var svc = new RealBondPaymentService(_bondPayments, OptUserInfo.UnitTestUser); return svc.CalcPayment(underlyingCode, fromDate, toDate, 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; return 1.00m; } public eod_swap_position ExecuteUpdateEodPosition(swap_position swapPosition, eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate, List unwindEvents) => UpdateEodPosition(swapPosition, eod, null, td, valueDate, preSettleDate, unwindEvents); public eod_swap_position ExecuteCopyEodPosition(eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate) => CopyEodPosition(eod, null, td, valueDate, preSettleDate); } #endregion #region 数据构建 private static trade CreateTrade() => new trade { id = SwapTradeId, TradeNumber = "UT-DIV-EOD-001", ClientId = 999999, TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, ExerciseDate = new DateTime(2027, 1, 5), TradeStatus = "确认成交", ValidState = "Valid", StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY", OriginalStockEqvNotional = (double)(InitialQty * 1.00m) }; private static swap_position CreatePosition() => 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 }; private static eod_swap_position CreateInitialEod() => 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 SwapEvent(decimal dividendIn, DateTime eventDate) => 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.完成 }; private static swap_flow_event CloseEvent(decimal qty, decimal dividendIn, DateTime eventDate) => 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 void AssertDecimalEqual(decimal expected, decimal actual, decimal tol, string msg) => Assert.IsTrue(Math.Abs(expected - actual) <= tol, $"{msg}: expected={expected} actual={actual}"); #endregion /// /// 盘中收益互换:DividendIn 由 GetPreEodDividendSum 真实算(读 T-1 EOD)→ 保存 → EOD。 /// 验证:不重复(EOD TdCloseDividend 扣 DividendIn)+ 不丢失(当日新计进 PosiDividendSum)+ 守恒。 /// /// 序列(StartDate=1/5,reg_date 1/6、1/7 各一期,每期 = qty×per100/100 = 10): /// D1=1/6 无事件 Copy:窗口(1/5,1/6] 命中 reg_date 1/6 → TdPosiDividend=10,PosiDividendSum=10 /// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_event;EOD 窗口(1/6,1/7] 命中 reg_date 1/7 → 新计 10 - 实现 10 → PosiDividendSum=10 /// 守恒:全程新计(10+10) - 全程实现(10) = 末尾 PosiDividendSum(10) /// [TestMethod] public void 盘中收益互换_DividendIn真实算_保存后EOD_不重复不丢失() { var eodSvc = new EodSvcStub(BondPayments()); var td = CreateTrade(); var position = CreatePosition(); var initialEod = CreateInitialEod(); // D1=1/6 无事件 EOD var d1 = new DateTime(2026, 1, 6); var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, d1, StartDate); AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum(0+1天×10)"); // D2=1/7 盘中:DividendIn 由生产方法 GetPreEodDividendSum 真实算(读 D1 EOD,当日 EOD 未生成) var d2 = new DateTime(2026, 1, 7); var dealSvc = new DealSvcStub( new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = d1 } }, new List { r1 }); decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(SwapTradeId, PositionId, d2); AssertDecimalEqual(10m, dividendIn, 0.01m, "盘中 DividendIn=GetPreEodDividendSum 读 T-1(D1)=10"); Console.WriteLine($"[盘中预览] DividendIn={dividendIn}(读 T-1 EOD PosiDividendSum={r1.PosiDividendSum})"); // 保存互换事件(DividendIn=真实算出的值,模拟界面点收益互换后保存) var swapEvent = SwapEvent(dividendIn, d2); // D2=1/7 EOD(UpdateEodPosition,真实生产递推) var r2 = eodSvc.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List { swapEvent }); // 断言:不重复 + 不丢失 AssertDecimalEqual(10m, r2.TdPosiDividend, 0.01m, "D2 当日新计(1天×10)"); AssertDecimalEqual(dividendIn, r2.TdCloseDividend, 0.01m, "D2 TdCloseDividend=互换DividendIn(扣减→不重复累计)"); AssertDecimalEqual(10m, r2.PosiDividendSum, 0.01m, "D2 PosiDividendSum=前日10+新计10-实现10=10(当日新计挂着→不丢失)"); // 守恒:全程新计 - 全程实现 = 末尾 PosiDividendSum decimal totalNew = r1.TdPosiDividend + r2.TdPosiDividend; decimal totalRealized = r2.TdCloseDividend; AssertDecimalEqual(r2.PosiDividendSum, totalNew - totalRealized, 0.01m, $"守恒:末尾 PosiDividendSum({r2.PosiDividendSum}) = 全程新计({totalNew}) - 全程实现({totalRealized})"); Console.WriteLine($"[EOD 后] TdPosiDividend={r2.TdPosiDividend} TdCloseDividend={r2.TdCloseDividend} PosiDividendSum={r2.PosiDividendSum}"); Console.WriteLine($"结论:互换实现 {dividendIn} 被扣减(不重复);当日新计 {r2.TdPosiDividend} 挂 PosiDividendSum(不丢失)"); } /// /// 登记日当日全平(盘中平仓→收盘持仓 0):按各交易场所规定,不享有登记日当日的分红 /// (股权登记日以收盘在册为准;盘中全平→收盘不在册)。验证系统行为符合该规定。 /// /// 系统行为:①盘中 DividendIn=GetPreEodDividendSum 读 T-1(=T日前待实现,正确不含登记日当日 reg_date 1/7 的分红); /// ②EOD 全平 PosiQuantity=0 → TdPosiDividend=0(不计提登记日当日 reg_date 1/7)+ PosiDividendSum=0。 /// 即登记日当日分红(reg_date 1/7 的 10)既不进 DividendIn、也不进 PosiDividendSum = 正确不享有。 /// 应得 = T日前待实现累计(r1.PosiDividendSum,仅含 1/6 那期 10);实拿 = DividendIn → 相等,无丢失(不享有当日是正确的)。 /// [TestMethod] public void 登记日全平_按交易场所规定不享有当日分红() { var eodSvc = new EodSvcStub(BondPayments()); var td = CreateTrade(); var position = CreatePosition(); var initialEod = CreateInitialEod(); // D1=1/6 无事件 EOD var d1 = new DateTime(2026, 1, 6); var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, d1, StartDate); AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum"); // D2=1/7 盘中全平:DividendIn 由生产方法真实算(读 D1 EOD,当日 EOD 未生成) var d2 = new DateTime(2026, 1, 7); var dealSvc = new DealSvcStub( new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = d1 } }, new List { r1 }); decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(SwapTradeId, PositionId, d2); AssertDecimalEqual(10m, dividendIn, 0.01m, "全平 DividendIn=读T-1(D1)=10(漏 D2 当日新计)"); // 全平事件(扣全部持仓) var closeEvent = CloseEvent(InitialQty, dividendIn, d2); // D2=1/7 EOD(UpdateEodPosition,全平→PosiQuantity=0) var r2 = eodSvc.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List { closeEvent }); // 业务规定:登记日当日全平(盘中平仓→收盘持仓为 0),按各交易场所规定不享有登记日当日的分红 // (股权登记日以收盘在册为准)。故应得 = T日(登记日)之前的待实现累计 = r1.PosiDividendSum(不含登记日当日)。 // 系统行为正确:①DividendIn 读 T-1(=T日前待实现,正确不含当日);②EOD 全平 PosiQuantity=0 不计提当日。 // 即登记日当日分红既不进 DividendIn 也不进 PosiDividendSum = 正确不享有。 decimal expectedTotal = r1.PosiDividendSum; // 应得 = T日前待实现(不含登记日当日,因全平不享有) decimal actualGot = dividendIn + r2.PosiDividendSum; Console.WriteLine($"[登记日全平] 应得(T日前待实现)={expectedTotal}, 实拿(DividendIn+PosiDividendSum)={actualGot}"); Console.WriteLine($"[登记日全平] DividendIn={dividendIn}, EOD:TdPosiDividend={r2.TdPosiDividend} PosiDividendSum={r2.PosiDividendSum} PosiQuantity={r2.PosiQuantity}"); // 断言:实拿 = 应得(登记日全平不享有当日,符合交易场所规定) AssertDecimalEqual(expectedTotal, actualGot, 0.01m, $"实拿应=应得(T日前待实现{expectedTotal}),登记日全平不享有当日分红(符合交易场所规定)"); AssertDecimalEqual(0m, r2.TdPosiDividend, 0.01m, "登记日全平 EOD 不计提当日(PosiQuantity=0,正确)"); AssertDecimalEqual(0m, r2.PosiDividendSum, 0.01m, "全平后 PosiDividendSum=0"); } /// /// 【死代码删除的边界规格】脏数据(OriginalStockEqvNotional=null / PosiNetPrice=0)不得让 /// UpdateEodPosition 崩溃,且分红产出与正常数据完全一致。 /// 背景:这两个字段在 UpdateEodPosition 内的唯一消费点是历史遗留死代码 /// (originNotional→totalPayment 全历史重算,结果从未被使用,2026-08 论证后删除)—— /// 删除前该脏数据会在 EOD 抛 InvalidOperationException/除零;删除后是设计内行为。 /// 本测试同时钉住:删除后输出等价(与同输入正常数据路径一致)。 /// [TestMethod] public void 脏数据边界_死代码涉及字段_不影响EOD分红产出() { // 正常数据基准 var eodSvcClean = new EodSvcStub(BondPayments()); var tdClean = CreateTrade(); var positionClean = CreatePosition(); var initialEod = CreateInitialEod(); var d1 = new DateTime(2026, 1, 6); var d2 = new DateTime(2026, 1, 7); var r1Clean = eodSvcClean.ExecuteCopyEodPosition(initialEod, tdClean, d1, StartDate); var r2Clean = eodSvcClean.ExecuteUpdateEodPosition(positionClean, r1Clean, tdClean, d2, d1, new List { CloseEvent(InitialQty, r1Clean.PosiDividendSum, d2) }); // 脏数据:死代码涉及的两字段置脏(活路径零消费,见方法内 grep 论证) var eodSvcDirty = new EodSvcStub(BondPayments()); var tdDirty = CreateTrade(); tdDirty.OriginalStockEqvNotional = null; // 死代码 (decimal) 强转崩溃点 var positionDirty = CreatePosition(); positionDirty.PosiNetPrice = 0m; // 死代码除零崩溃点 var r1Dirty = eodSvcDirty.ExecuteCopyEodPosition(initialEod, tdDirty, d1, StartDate); var r2Dirty = eodSvcDirty.ExecuteUpdateEodPosition(positionDirty, r1Dirty, tdDirty, d2, d1, new List { CloseEvent(InitialQty, r1Dirty.PosiDividendSum, d2) }); // 脏数据不崩 + 输出与正常数据逐字段一致 AssertDecimalEqual(r2Clean.TdPosiDividend, r2Dirty.TdPosiDividend, 0.0001m, "TdPosiDividend 不受脏字段影响"); AssertDecimalEqual(r2Clean.TdCloseDividend, r2Dirty.TdCloseDividend, 0.0001m, "TdCloseDividend 不受脏字段影响"); AssertDecimalEqual(r2Clean.PosiDividendSum, r2Dirty.PosiDividendSum, 0.0001m, "PosiDividendSum 不受脏字段影响"); AssertDecimalEqual(r2Clean.RealizedDividend, r2Dirty.RealizedDividend, 0.0001m, "RealizedDividend 不受脏字段影响"); Console.WriteLine($"[脏数据边界] 正常={r2Clean.PosiDividendSum} 脏数据={r2Dirty.PosiDividendSum}(应相等且不抛异常)"); } } }