fix(dividend): 分红按债权登记日(reg_date)口径计提,手动互换读取当日EOD快照 (GLMS-20260105-0006)
- BondPayment 实体补映射 reg_date(债权登记日,对应 bond_payment_info.reg_date 列) - BondPaymentService.GetBondPayments 改按 reg_date 过滤(原错用 pay_date_PL/支付日;pay_date_PL 是理论付息日,仍属支付日,非登记日) - SwapDealService.GetPreEodPositionByDate 由 ValueDate < dealDate 改 <=,登记日当天手动互换读当日EOD分红而非T-1 - 新增 GLMS20260105_0006_RegisterDateDividendTest 回归测试(手工合成内存,先RED复现后GREEN 2/2) 根因:bond_payment_info 表仅有 pay_date_PL(理论付息日)/pay_date_act(实际付息日) 两个支付日,缺登记日; 代码用支付日判定票息归属,且手动互换只读 T-1 EOD 快照;登记日≠支付日(恰差一工作日)时缺陷被掩盖, 导致对话框分红收益显示 0。
This commit is contained in:
@@ -65,6 +65,13 @@ namespace YLErp.DBModels
|
||||
[DisplayName("实际付息(兑付)日")]
|
||||
[Column("pay_date_act")]
|
||||
public DateTime? payment_date { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 债权登记日(除息/归属截止日)——票息归属按此判定,而非支付日
|
||||
/// </summary>
|
||||
[DisplayName("债权登记日")]
|
||||
[Column("reg_date")]
|
||||
public DateTime? reg_date { get; set; }
|
||||
/// <summary>
|
||||
/// 每张兑付利息额
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using YLErp.Modules.EodModule;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 注入,真实跑生产日期逻辑。
|
||||
/// </summary>
|
||||
[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<BondPayment> _data;
|
||||
public TestableBondPaymentService(List<BondPayment> data) : base(OptUserInfo.UnitTestUser) { _data = data; }
|
||||
|
||||
protected override IQueryable<BondPayment> 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<BondPayment> { 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<eod_swap> _eodSwaps;
|
||||
private readonly List<eod_swap_position> _eodPositions;
|
||||
public TestableSwapDealService(List<eod_swap> eodSwaps, List<eod_swap_position> eodPositions)
|
||||
: base(OptUserInfo.UnitTestUser) { _eodSwaps = eodSwaps; _eodPositions = eodPositions; }
|
||||
|
||||
public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
|
||||
=> GetPreEodDividendSum(tradeId, positionId, dealDate);
|
||||
|
||||
protected override IQueryable<eod_swap> 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<eod_swap>
|
||||
{
|
||||
new eod_swap { SwapTradeId = TradeId, ValueDate = PreRegDate },
|
||||
new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate }
|
||||
};
|
||||
var eodPositions = new List<eod_swap_position>
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -98,10 +98,21 @@ namespace YLErp.Modules.EodModule
|
||||
/// <returns></returns>
|
||||
public List<BondPayment> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 可测性 seam:返回某债券的全部付息记录(未做日期过滤)。测试可 override 注入内存数据,
|
||||
/// 以验证日期口径(GLMS-20260105-0006:应按债权登记日 reg_date 而非支付日 pay_date_PL/pay_date_act 判定)。
|
||||
/// </summary>
|
||||
protected virtual IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
|
||||
=> DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode);
|
||||
|
||||
|
||||
public List<BondPayment> GetTargetDatePayments(string underlyingCode, DateTime targetDate)
|
||||
{
|
||||
|
||||
@@ -812,12 +812,13 @@ namespace YLErp.Modules.SwapModule
|
||||
|
||||
/// <summary>
|
||||
/// 取 dealDate 对应"上一收盘日"持仓的累计分红快照。
|
||||
/// 内部用 ValueDate 严格小于 dealDate 定位上一收盘日(登记日当天手动平仓会因此读到 T-1 快照,见 GLMS-20260105-0006)。
|
||||
/// GLMS-20260105-0006:登记日当天手动平仓/互换时,当日 EOD 快照已含分红,应取到当日而非 T-1。
|
||||
/// 故由 ValueDate 严格小于 dealDate 改为 小于等于:当日 EOD 存在则读当日,否则回退上一收盘日(原口径不变)。
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user