diff --git a/UnitTestProject/Modules/SwapModule/RegDateDividendEodE2ETest.cs b/UnitTestProject/Modules/SwapModule/RegDateDividendEodE2ETest.cs new file mode 100644 index 00000000..38b58df9 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/RegDateDividendEodE2ETest.cs @@ -0,0 +1,270 @@ +using YLErp; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.EodModule; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Linq; + +namespace YLErp.Modules.SwapModule +{ + /// + /// GLMS-20260105-0006 端到端补充:EOD 分红引擎的票息归属须按【债权登记日 reg_date】判定, + /// 而非支付日(pay_date)。此前 DividendEodNoDoubleCountTest.EodSvcStub 把 CalcBondPayment 覆写成 + /// 线性公式(DailyRatePerUnit*days*qty),**绕开了 reg_date 口径**——即没有真正验证"引擎按登记日计提"。 + /// + /// 本文件把 EOD stub 的 CalcBondPayment seam 重新桥接回【真实的 BondPaymentService(reg_date 口径)】, + /// 仅用内存 BondPayment 数据(不连库),使端到端流程(CopyEodPosition/UpdateEodPosition + GetPreEodDividendSum) + /// 真正跑生产日期逻辑: + /// ① EOD 引擎在登记日计提、支付日不计提(证明 reg_date 口径); + /// ② 登记日下一日(T+1)全平:经 GetPreEodDividendSum 读到登记日当日 EOD 分红(收盘在册→享有); + /// ③ 部分平仓 T+1:DividendIn 为全量(非按比例缩放),剩余 PosiDividendSum 归 0(记录当前生产行为)。 + /// + [TestClass] + public class RegDateDividendEodE2ETest + { + private const string BondCode = "230004.IB"; + private const int TradeId = 7004; + private const long PositionId = 70041; + private const decimal Qty = 20_000_000m; + private const decimal PaymentPer100 = 0.1808m; + private const decimal ExpectedDividend = 36_160m; // 20,000,000 × 0.1808 / 100 + + private static readonly DateTime StartDate = new(2026, 4, 1); + private static readonly DateTime RegDate = new(2026, 4, 3); // 债权登记日 + private static readonly DateTime PayDate = new(2026, 4, 6); // 实际支付日(与登记日差 3 天) + + #region 内存债券付息数据(reg_date 口径) + + private static List BondPayments() + => new List + { + new BondPayment + { + underlyingCode = BondCode, + reg_date = RegDate, // 关键:分红归属按债权登记日判定 + payment_date_pl = PayDate, // 理论付息日(非归属口径) + payment_date = PayDate, // 实际付息日(非归属口径) + payment_interest = PaymentPer100 + } + }; + + #endregion + + #region BondPaymentService seam(桥接真实 reg_date 口径,内存数据) + + private sealed class RegDateBondPaymentService : BondPaymentService + { + private readonly List _data; + public RegDateBondPaymentService(List data, OptUserInfo userInfo) : base(userInfo) { _data = data; } + protected override IQueryable QueryBondPayments(string underlyingCode) + => _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable(); + } + + #endregion + + #region EOD stub(CalcBondPayment 桥接真实 BondPaymentService) + + private sealed class RegDateEodStub : TestableSwapEodPositionService + { + private readonly List _bondPayments; + public RegDateEodStub(List bondPayments) : base(nameof(RegDateDividendEodE2ETest)) { _bondPayments = bondPayments; } + + protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) + { + // 桥接真实生产口径:BondPaymentService.GetBondPayments 按 reg_date 过滤 + CalcPayment 累加 + var svc = new RegDateBondPaymentService(_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 ExecuteCopyEodPosition(eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate) + => CopyEodPosition(eod, null, td, valueDate, preSettleDate); + + 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); + } + + #endregion + + #region Deal 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); + } + + #endregion + + #region 数据构建 + + private static trade CreateTrade() => new trade + { + id = TradeId, TradeNumber = "UT-REGDATE-E2E-001", ClientId = 999999, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = new DateTime(2027, 4, 1), TradeStatus = "确认成交", ValidState = "Valid", + StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY", + OriginalStockEqvNotional = (double)(Qty * 1.00m) + }; + + private static swap_position CreatePosition() => new swap_position + { + id = PositionId, SwapTradeId = TradeId, + PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = BondCode, ContractSize = 1m, + PosiQuantity = Qty, PosiNotionalValue = Qty, + 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 = TradeId, PositionId = PositionId, + ValueDate = StartDate, PosiQuantity = Qty, + PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = BondCode, 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) => new swap_flow_event + { + SwapTradeId = TradeId, 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(System.Math.Abs(expected - actual) <= tol, $"{msg}: expected={expected} actual={actual}"); + + #endregion + + /// + /// 端到端证 reg_date 口径:EOD 引擎(CopyEodPosition)逐日计提时, + /// 仅在【债权登记日】产生分红,【支付日】不产生(即便支付日与登记日相差数日)。 + /// 这是线性 stub 无法覆盖的——线性公式按"天数"算,永远无法区分登记日 vs 支付日。 + /// + [TestMethod] + public void 登记日口径_EOD引擎按reg_date计提_非pay_date() + { + var eodSvc = new RegDateEodStub(BondPayments()); + var td = CreateTrade(); + var initialEod = CreateInitialEod(); + + // D1=4/2(登记日前一日):窗口 (4/1,4/2] 无登记日 → 0 + var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, new DateTime(2026, 4, 2), StartDate); + AssertDecimalEqual(0m, r1.TdPosiDividend, 0.01m, "4/2 当日新计(无登记日)"); + AssertDecimalEqual(0m, r1.PosiDividendSum, 0.01m, "4/2 累计(无登记日)"); + + // D2=4/3(登记日):窗口 (4/2,4/3] 命中 reg_date=4/3 → 36160 + var r2 = eodSvc.ExecuteCopyEodPosition(r1, td, RegDate, StartDate); + AssertDecimalEqual(ExpectedDividend, r2.TdPosiDividend, 0.01m, + "4/3 登记日当日应计提 36160(按 reg_date 口径);若按支付日(pay_date=4/6)则此处为 0(漏计)。"); + AssertDecimalEqual(ExpectedDividend, r2.PosiDividendSum, 0.01m, "4/3 累计=36160"); + + // D3=4/6(支付日,非登记日):窗口 (4/3,4/6] 不含任何 reg_date(4/3 不>4/3;4/6 是支付日非登记日)→ 0 + var r3 = eodSvc.ExecuteCopyEodPosition(r2, td, PayDate, StartDate); + AssertDecimalEqual(0m, r3.TdPosiDividend, 0.01m, + "4/6 支付日不应计提(分红归属按 reg_date,不是 pay_date);线性 stub 因按天数算会在此误计。"); + AssertDecimalEqual(ExpectedDividend, r3.PosiDividendSum, 0.01m, "4/6 累计仍为 36160(支付日不重复计提)"); + + Console.WriteLine($"[reg_date 口径] 4/2={r1.PosiDividendSum}, 4/3={r2.PosiDividendSum}(登记日计提), 4/6={r3.PosiDividendSum}(支付日不计提)"); + } + + /// + /// 用户场景「登记日下一日(T+1)全平」:T日(登记日)收盘在册→享有T日分红; + /// T+1盘中全平,GetPreEodDividendSum(T+1) 应读到 T日 EOD(含当日分红)= 36160,而非漏读为 0。 + /// 验证端到端:EOD 引擎算出 T日分红 → 快照 → 手动/互换读取正确取到。 + /// + [TestMethod] + public void 登记日下一日全平_经GetPreEodDividendSum读到登记日分红() + { + var eodSvc = new RegDateEodStub(BondPayments()); + var td = CreateTrade(); + var position = CreatePosition(); + var initialEod = CreateInitialEod(); + + // T日=4/3(登记日)EOD:引擎算出分红 36160(reg_date 口径) + var rReg = eodSvc.ExecuteCopyEodPosition(initialEod, td, RegDate, StartDate); + AssertDecimalEqual(ExpectedDividend, rReg.PosiDividendSum, 0.01m, "登记日 T日 EOD 累计分红=36160"); + + // T+1=4/4 盘中:注入 T日 EOD 快照,GetPreEodDividendSum 应读 T日(<=当日) → 36160 + var dealSvc = new DealSvcStub( + new List { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } }, + new List { rReg }); + decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 4)); + AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m, + "T+1(4/4) 盘中全平应经 GetPreEodDividendSum 读到 T日(4/3)EOD 分红 36160(收盘在册→享有);" + + "若 < 严格小于 dealDate 读 T-1(4/2=0) 则漏读登记日当日。"); + Console.WriteLine($"[T+1 全平] DividendIn(读T日EOD)={dividendIn}"); + + // T+1=4/4 EOD 全平:PosiQuantity=0 → 不计提当日 + PosiDividendSum 归 0 + var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rReg, td, new DateTime(2026, 4, 4), RegDate, + new List { CloseEvent(Qty, dividendIn, new DateTime(2026, 4, 4)) }); + + // 实拿 = DividendIn(本次落袋) + 末尾 PosiDividendSum(剩余挂账) = 应得(T日前待实现=持有至登记日) + decimal actualGot = dividendIn + rT1.PosiDividendSum; + AssertDecimalEqual(ExpectedDividend, actualGot, 0.01m, "实拿=应得(持有至登记日享有的 36160)"); + AssertDecimalEqual(0m, rT1.TdPosiDividend, 0.01m, "T+1 非登记日,EOD 不计提当日"); + AssertDecimalEqual(0m, rT1.PosiDividendSum, 0.01m, "全平后 PosiDividendSum=0"); + Console.WriteLine($"[T+1 全平] 应得={ExpectedDividend}, 实拿={actualGot}, 末尾PosiDividendSum={rT1.PosiDividendSum}"); + } + + /// + /// 部分平仓 T+1:当前生产行为记录(非修复目标)。 + /// T日(登记日)持有→T+1盘中部分平仓:GetPreEodDividendSum 返回的是【全量】待实现分红(非按平仓比例缩放), + /// 故 DividendIn=全量 36160;T+1 EOD 部分平仓(PosiQuantity>0)后剩余 PosiDividendSum=前日-全量=0。 + /// 注:此"DividendIn 不按平仓比例缩放"是当前生产行为,已与用户确认(潜在一致性议题,非本 bug 修复范围)。 + /// + [TestMethod] + public void 部分平仓_T1_DividendIn为全量_剩余PosiDividendSum归0() + { + var eodSvc = new RegDateEodStub(BondPayments()); + var td = CreateTrade(); + var position = CreatePosition(); + var initialEod = CreateInitialEod(); + + // T日=4/3(登记日)EOD:累计 36160 + var rReg = eodSvc.ExecuteCopyEodPosition(initialEod, td, RegDate, StartDate); + AssertDecimalEqual(ExpectedDividend, rReg.PosiDividendSum, 0.01m, "登记日 T日 EOD 累计=36160"); + + // T+1=4/4 盘中部分平仓(50%):GetPreEodDividendSum 返回【全量】36160(不按比例缩放) + var dealSvc = new DealSvcStub( + new List { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } }, + new List { rReg }); + decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 4)); + AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m, "部分平仓 T+1:DividendIn 仍为全量 36160(非按 50% 缩放)"); + + // T+1=4/4 EOD 部分平仓(Quantity=Qty/2):PosiQuantity>0;TdPosiDividend=0(非登记日), + // PosiDividendSum = 前日36160 + 0 - TdCloseDividend(全量36160) = 0 + var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rReg, td, new DateTime(2026, 4, 4), RegDate, + new List { CloseEvent(Qty / 2, dividendIn, new DateTime(2026, 4, 4)) }); + + AssertDecimalEqual(ExpectedDividend, rT1.TdCloseDividend, 0.01m, "TdCloseDividend=全量 DividendIn(36160)"); + AssertDecimalEqual(0m, rT1.PosiDividendSum, 0.01m, + "部分平仓后剩余 PosiDividendSum=前日36160 - 全量实现36160 = 0(当前生产行为:DividendIn 不按比例缩放)"); + Console.WriteLine($"[部分平仓 T+1] DividendIn={dividendIn}(全量), 剩余PosiDividendSum={rT1.PosiDividendSum}"); + } + } +}