- 将 BondPaymentService.GetBondPayments 方法的过滤条件从 reg_date 改为 payment_date - 更新相关日志信息显示 [分红-付息日口径] 替代 [分红-登记日口径] - 修正单元测试中的注释和变量名,统一使用 payment_date 口径描述 - 修改测试数据构造,验证按实际付息日进行票息计提的逻辑 - 更新相关文档注释,明确日期口径变更的具体实现方式
255 lines
14 KiB
C#
255 lines
14 KiB
C#
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>
|
||
/// EOD 分红引擎票息归属按【实际付息日 payment_date】判定测试。
|
||
/// </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); // 实际支付日
|
||
|
||
#region 内存债券付息数据(payment_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(桥接真实 payment_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 stub(CalcBondPayment 桥接真实 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 按 payment_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 stub(GetPreEodDividendSum,注入 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-PAYDATE-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>
|
||
/// 端到端证 payment_date 口径:EOD 引擎(CopyEodPosition)逐日计提时,
|
||
/// 仅在【实际付息日】产生分红,【登记日/非付息日】不产生。
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void 付息日口径_EOD引擎按payment_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] 无付息日 → 0
|
||
var r2 = eodSvc.ExecuteCopyEodPosition(r1, td, RegDate, StartDate);
|
||
AssertDecimalEqual(0m, r2.TdPosiDividend, 0.01m, "4/3 登记日(非付息日)不计提");
|
||
AssertDecimalEqual(0m, r2.PosiDividendSum, 0.01m, "4/3 累计=0");
|
||
|
||
// D3=4/6(付息日):窗口 (4/3,4/6] 命中 payment_date=4/6 → 36160
|
||
var r3 = eodSvc.ExecuteCopyEodPosition(r2, td, PayDate, StartDate);
|
||
AssertDecimalEqual(ExpectedDividend, r3.TdPosiDividend, 0.01m, "4/6 付息日当日应计提 36160");
|
||
AssertDecimalEqual(ExpectedDividend, r3.PosiDividendSum, 0.01m, "4/6 累计为 36160");
|
||
|
||
Console.WriteLine($"[payment_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。
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void 付息日下一日全平_经GetPreEodDividendSum读到付息日分红()
|
||
{
|
||
var eodSvc = new RegDateEodStub(BondPayments());
|
||
var td = CreateTrade();
|
||
var position = CreatePosition();
|
||
var initialEod = CreateInitialEod();
|
||
|
||
// T日=4/6(付息日)EOD:引擎算出分红 36160(payment_date 口径)
|
||
var rPay = eodSvc.ExecuteCopyEodPosition(initialEod, td, PayDate, StartDate);
|
||
AssertDecimalEqual(ExpectedDividend, rPay.PosiDividendSum, 0.01m, "付息日 T日 EOD 累计分红=36160");
|
||
|
||
// T+1=4/7 盘中:注入 T日 EOD 快照,GetPreEodDividendSum 应读 T日(<=当日) → 36160
|
||
var dealSvc = new DealSvcStub(
|
||
new List<eod_swap> { new eod_swap { SwapTradeId = TradeId, ValueDate = PayDate } },
|
||
new List<eod_swap_position> { rPay });
|
||
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 7));
|
||
AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m,
|
||
"T+1(4/7) 盘中全平应经 GetPreEodDividendSum 读到 T日(4/6)EOD 分红 36160");
|
||
Console.WriteLine($"[T+1 全平] DividendIn(读T日EOD)={dividendIn}");
|
||
|
||
// T+1=4/7 EOD 全平:PosiQuantity=0 → 不计提当日 + PosiDividendSum 归 0
|
||
var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rPay, td, new DateTime(2026, 4, 7), PayDate,
|
||
new List<swap_flow_event> { CloseEvent(Qty, dividendIn, new DateTime(2026, 4, 7)) });
|
||
|
||
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=全量 36160;T+1 EOD 部分平仓后剩余 PosiDividendSum=前日-全量=0。
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void 部分平仓_T1_DividendIn为全量_剩余PosiDividendSum归0()
|
||
{
|
||
var eodSvc = new RegDateEodStub(BondPayments());
|
||
var td = CreateTrade();
|
||
var position = CreatePosition();
|
||
var initialEod = CreateInitialEod();
|
||
|
||
// T日=4/6(付息日)EOD:累计 36160
|
||
var rPay = eodSvc.ExecuteCopyEodPosition(initialEod, td, PayDate, StartDate);
|
||
AssertDecimalEqual(ExpectedDividend, rPay.PosiDividendSum, 0.01m, "付息日 T日 EOD 累计=36160");
|
||
|
||
// T+1=4/7 盘中部分平仓(50%):GetPreEodDividendSum 返回全量 36160
|
||
var dealSvc = new DealSvcStub(
|
||
new List<eod_swap> { new eod_swap { SwapTradeId = TradeId, ValueDate = PayDate } },
|
||
new List<eod_swap_position> { rPay });
|
||
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(TradeId, PositionId, new DateTime(2026, 4, 7));
|
||
AssertDecimalEqual(ExpectedDividend, dividendIn, 0.01m, "部分平仓 T+1:DividendIn 仍为全量 36160");
|
||
|
||
// T+1=4/7 EOD 部分平仓(Quantity=Qty/2):PosiQuantity>0;TdPosiDividend=0(非付息日),
|
||
// PosiDividendSum = 前日36160 + 0 - TdCloseDividend(全量36160) = 0
|
||
var rT1 = eodSvc.ExecuteUpdateEodPosition(position, rPay, td, new DateTime(2026, 4, 7), PayDate,
|
||
new List<swap_flow_event> { CloseEvent(Qty / 2, dividendIn, new DateTime(2026, 4, 7)) });
|
||
|
||
AssertDecimalEqual(ExpectedDividend, rT1.TdCloseDividend, 0.01m, "TdCloseDividend=全量 DividendIn(36160)");
|
||
AssertDecimalEqual(0m, rT1.PosiDividendSum, 0.01m,
|
||
"部分平仓后剩余 PosiDividendSum=前日36160 - 全量实现36160 = 0");
|
||
Console.WriteLine($"[部分平仓 T+1] DividendIn={dividendIn}(全量), 剩余PosiDividendSum={rT1.PosiDividendSum}");
|
||
}
|
||
}
|
||
}
|