Files
zszq-trs/UnitTestProject/Modules/SwapModule/RegDateDividendEodE2ETest.cs
T

271 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// GLMS-20260105-0006 端到端补充:EOD 分红引擎的票息归属须按【债权登记日 reg_date】判定,
/// 而非支付日(pay_date)。此前 DividendEodNoDoubleCountTest.EodSvcStub 把 CalcBondPayment 覆写成
/// 线性公式(DailyRatePerUnit*days*qty)**绕开了 reg_date 口径**——即没有真正验证"引擎按登记日计提"。
///
/// 本文件把 EOD stub 的 CalcBondPayment seam 重新桥接回【真实的 BondPaymentServicereg_date 口径)】,
/// 仅用内存 BondPayment 数据(不连库),使端到端流程(CopyEodPosition/UpdateEodPosition + GetPreEodDividendSum)
/// 真正跑生产日期逻辑:
/// ① EOD 引擎在登记日计提、支付日不计提(证明 reg_date 口径);
/// ② 登记日下一日(T+1)全平:经 GetPreEodDividendSum 读到登记日当日 EOD 分红(收盘在册→享有);
/// ③ 部分平仓 T+1DividendIn 为全量(非按比例缩放),剩余 PosiDividendSum 归 0(记录当前生产行为)。
/// </summary>
[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<BondPayment> BondPayments()
=> new List<BondPayment>
{
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<BondPayment> _data;
public RegDateBondPaymentService(List<BondPayment> data, OptUserInfo userInfo) : base(userInfo) { _data = data; }
protected override IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
=> _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable();
}
#endregion
#region EOD stubCalcBondPayment 桥接真实 BondPaymentService
private sealed class RegDateEodStub : TestableSwapEodPositionService
{
private readonly List<BondPayment> _bondPayments;
public RegDateEodStub(List<BondPayment> 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<swap_flow_event> unwindEvents)
=> UpdateEodPosition(swapPosition, eod, null, td, valueDate, preSettleDate, unwindEvents);
}
#endregion
#region Deal stubGetPreEodDividendSum,注入 EOD 快照)
private sealed class DealSvcStub : SwapDealService
{
private readonly List<eod_swap> _eodSwaps;
private readonly List<eod_swap_position> _eodPositions;
public DealSvcStub(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);
}
#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
/// <summary>
/// 端到端证 reg_date 口径:EOD 引擎(CopyEodPosition)逐日计提时,
/// 仅在【债权登记日】产生分红,【支付日】不产生(即便支付日与登记日相差数日)。
/// 这是线性 stub 无法覆盖的——线性公式按"天数"算,永远无法区分登记日 vs 支付日。
/// </summary>
[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_date4/3 不>4/34/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}(支付日不计提)");
}
/// <summary>
/// 用户场景「登记日下一日(T+1)全平」:T日(登记日)收盘在册→享有T日分红;
/// T+1盘中全平,GetPreEodDividendSum(T+1) 应读到 T日 EOD(含当日分红)= 36160,而非漏读为 0。
/// 验证端到端:EOD 引擎算出 T日分红 → 快照 → 手动/互换读取正确取到。
/// </summary>
[TestMethod]
public void 登记日下一日全平_经GetPreEodDividendSum读到登记日分红()
{
var eodSvc = new RegDateEodStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
// T日=4/3(登记日)EOD:引擎算出分红 36160reg_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<eod_swap> { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } },
new List<eod_swap_position> { 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<swap_flow_event> { 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}");
}
/// <summary>
/// 部分平仓 T+1:当前生产行为记录(非修复目标)。
/// T日(登记日)持有→T+1盘中部分平仓:GetPreEodDividendSum 返回的是【全量】待实现分红(非按平仓比例缩放),
/// 故 DividendIn=全量 36160T+1 EOD 部分平仓(PosiQuantity>0)后剩余 PosiDividendSum=前日-全量=0。
/// 注:此"DividendIn 不按平仓比例缩放"是当前生产行为,已与用户确认(潜在一致性议题,非本 bug 修复范围)。
/// </summary>
[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<eod_swap> { new eod_swap { SwapTradeId = TradeId, ValueDate = RegDate } },
new List<eod_swap_position> { rReg });
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 4));
AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m, "部分平仓 T+1DividendIn 仍为全量 36160(非按 50% 缩放)");
// T+1=4/4 EOD 部分平仓(Quantity=Qty/2)PosiQuantity>0TdPosiDividend=0(非登记日)
// PosiDividendSum = 前日36160 + 0 - TdCloseDividend(全量36160) = 0
var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rReg, td, new DateTime(2026, 4, 4), RegDate,
new List<swap_flow_event> { 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}");
}
}
}