diff --git a/Framework/YLErp.Core/DBModels/BondPayment.cs b/Framework/YLErp.Core/DBModels/BondPayment.cs index aae29465..3de640b4 100644 --- a/Framework/YLErp.Core/DBModels/BondPayment.cs +++ b/Framework/YLErp.Core/DBModels/BondPayment.cs @@ -65,6 +65,13 @@ namespace YLErp.DBModels [DisplayName("实际付息(兑付)日")] [Column("pay_date_act")] public DateTime? payment_date { get; set; } + + /// + /// 债权登记日(除息/归属截止日)——票息归属按此判定,而非支付日 + /// + [DisplayName("债权登记日")] + [Column("reg_date")] + public DateTime? reg_date { get; set; } /// /// 每张兑付利息额 /// diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260105_0006_RegisterDateDividendTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260105_0006_RegisterDateDividendTest.cs new file mode 100644 index 00000000..4ad902b2 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GLMS20260105_0006_RegisterDateDividendTest.cs @@ -0,0 +1,112 @@ +using YLErp.Modules.EodModule; + +namespace YLErp.Modules.SwapModule +{ + /// + /// GLMS-20260105-0006 回归:债券 TRS 登记日当天手动平仓/互换,分红收益应为 36160 而非 0。 + /// 根因双成因: + /// A. BondPaymentService.GetBondPayments 用支付日(pay_date_PL/pay_date_act)而非债权登记日(reg_date)判定谁享有票息 + /// -> 登记日(4/3)当日 EOD 不计提,跨过支付日(4/6)才计提(巧合:4/4-4/5周末,下一交易日恰=支付日,掩盖缺陷) + /// B. SwapDealService.GetPreEodDividendSum 用 ValueDate 严格小于 dealDate 读 T-1 EOD 快照 + /// -> 登记日当天手动平仓读不到当日 EOD,拿到 0 + /// 本文件用手工合成内存数据(不连 96 库),通过 virtual seam 注入,真实跑生产日期逻辑。 + /// + [TestClass] + public class GLMS20260105_0006_RegisterDateDividendTest + { + private const string BondCode = "230004.IB"; + private const int TradeId = 6006; + private const long PositionId = 60061; + 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 + + // 付息日历(截图):登记日 4/3,支付日 4/6 + private static readonly DateTime RegDate = new(2026, 4, 3); + private static readonly DateTime PayDate = new(2026, 4, 6); + private static readonly DateTime PreRegDate = new(2026, 4, 2); + + #region 成因 A:日期口径 seam + + private sealed class TestableBondPaymentService : BondPaymentService + { + private readonly List _data; + public TestableBondPaymentService(List data) : base(OptUserInfo.UnitTestUser) { _data = data; } + + protected override IQueryable QueryBondPayments(string underlyingCode) + => _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable(); + } + + [TestMethod] + public void CauseA_登记日当日EOD_应按登记日口径选中付息记录() + { + var record = new BondPayment + { + underlyingCode = BondCode, + reg_date = RegDate, // 债权登记日 4/3(关键:分红归属按此判定) + payment_date_pl = PayDate, // 理论付息日 4/6 + payment_date = PayDate, // 实际付息日 4/6 + payment_interest = PaymentPer100 + }; + var svc = new TestableBondPaymentService(new List { record }); + + // 登记日当日的 EOD 计提区间 (4/2, 4/3] + var payments = svc.GetBondPayments(BondCode, PreRegDate, RegDate); + + // 修复前:用支付日(pay_date_PL=4/6)过滤 -> 4/6 不在 (4/2,4/3] -> 0 条(漏计分红) + // 修复后:用债权登记日(reg_date=4/3)过滤 -> 4/3 落在区间 -> 1 条(GLMS-20260105-0006 已修复) + Assert.AreEqual(1, payments.Count, + "登记日(4/3)当日 EOD 应按债权登记日(reg_date)选中该笔付息;" + + "当前按支付日(pay_date_PL=4/6)过滤会漏选->0条,导致分红不计提。"); + } + + #endregion + + #region 成因 B:T-1 快照 seam + + private sealed class TestableSwapDealService : SwapDealService + { + private readonly List _eodSwaps; + private readonly List _eodPositions; + public TestableSwapDealService(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); + } + + [TestMethod] + public void CauseB_登记日当天手动平仓_应读到当日EOD分红36160() + { + // 4/2 EOD:累计分红 0;4/3 EOD(登记日):累计分红 36160(即登记日应有的状态) + var eodSwaps = new List + { + new eod_swap { SwapTradeId = TradeId, ValueDate = PreRegDate }, + new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } + }; + var eodPositions = new List + { + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = PreRegDate, PosiDividendSum = 0m, PosiQuantity = Qty }, + new eod_swap_position { SwapTradeId = TradeId, PositionId = PositionId, ValueDate = RegDate, PosiDividendSum = ExpectedDividend, PosiQuantity = Qty } + }; + var svc = new TestableSwapDealService(eodSwaps, eodPositions); + + // 登记日(4/3)当天手动平仓 + var dividend = svc.ExposeGetPreEodDividendSum(TradeId, PositionId, RegDate); + + // 修复前:ValueDate 严格小于 dealDate 读 T-1(4/2) -> 0(漏读当日分红) + // 修复后:ValueDate 小于等于 dealDate 读当日(4/3) -> 36160(GLMS-20260105-0006 已修复) + Assert.AreEqual(ExpectedDividend, dividend, 0.01m, + "登记日(4/3)当天手动平仓应读到当日 EOD 累计分红 36,160;" + + "当前 GetPreEodDividendSum 用 ValueDate < dealDate 读 T-1 快照->0。"); + } + + #endregion + } +} diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs index a10caab3..3e89b5b4 100644 --- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs +++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs @@ -98,10 +98,21 @@ namespace YLErp.Modules.EodModule /// public List GetBondPayments(string underlyingCode, DateTime startDate, DateTime endDate) { - var result = DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode && x.payment_date > startDate && x.payment_date <= endDate).AsNoTracking().ToList(); + // GLMS-20260105-0006:票息归属按债权登记日(reg_date)判定,而非支付日(pay_date_PL/pay_date_act)。 + // 登记日当天 EOD 即应计提;原按支付日口径会让"登记日≠支付日"的债券漏计(二者恰差一工作日时缺陷被掩盖)。 + var result = QueryBondPayments(underlyingCode) + .Where(x => x.reg_date > startDate && x.reg_date <= endDate) + .AsNoTracking().ToList(); return result; } + /// + /// 可测性 seam:返回某债券的全部付息记录(未做日期过滤)。测试可 override 注入内存数据, + /// 以验证日期口径(GLMS-20260105-0006:应按债权登记日 reg_date 而非支付日 pay_date_PL/pay_date_act 判定)。 + /// + protected virtual IQueryable QueryBondPayments(string underlyingCode) + => DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode); + public List GetTargetDatePayments(string underlyingCode, DateTime targetDate) { diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index f0325f0b..13cf78d8 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -812,12 +812,13 @@ namespace YLErp.Modules.SwapModule /// /// 取 dealDate 对应"上一收盘日"持仓的累计分红快照。 - /// 内部用 ValueDate 严格小于 dealDate 定位上一收盘日(登记日当天手动平仓会因此读到 T-1 快照,见 GLMS-20260105-0006)。 + /// GLMS-20260105-0006:登记日当天手动平仓/互换时,当日 EOD 快照已含分红,应取到当日而非 T-1。 + /// 故由 ValueDate 严格小于 dealDate 改为 小于等于:当日 EOD 存在则读当日,否则回退上一收盘日(原口径不变)。 /// protected virtual eod_swap_position GetPreEodPositionByDate(int tradeId, long positionId, DateTime dealDate) { var lastEod = QueryPreEodSwaps(tradeId) - .Where(x => x.ValueDate < dealDate) + .Where(x => x.ValueDate <= dealDate) .OrderByDescending(o => o.ValueDate).FirstOrDefault(); var preEodDate = lastEod == null ? dealDate.AddDays(-1) : lastEod.ValueDate; return QueryPreEodPosition(tradeId, positionId, preEodDate);