Files
zszq-trs/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs
T
张名锐 ac7b04e79d fix(bond): #JTZG-136 国联民生-有期间付息,自动互换的的时候互换日期不正确
- 将 BondPaymentService.GetBondPayments 方法的过滤条件从 reg_date 改为 payment_date
- 更新相关日志信息显示 [分红-付息日口径] 替代 [分红-登记日口径]
- 修正单元测试中的注释和变量名,统一使用 payment_date 口径描述
- 修改测试数据构造,验证按实际付息日进行票息计提的逻辑
- 更新相关文档注释,明确日期口径变更的具体实现方式
2026-08-26 13:40:59 +08:00

277 lines
17 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;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 端到端:盘中收益互换(DividendIn 由生产方法 GetPreEodDividendSum 真实算出)→ 保存 → EOD,
/// 验证分红【不重复累计】(EOD TdCloseDividend 扣减 DividendIn)且【不丢失】(当日新计进 PosiDividendSum)。
///
/// 与 MultiUnwindDividendConservationTest.MU_001 的区别:MU_001 的互换 DividendIn 是测试喂的常量;
/// 本测试的 DividendIn 由生产方法 GetPreEodDividendSum 真实算出(读 EOD 快照),再喂给 EOD——
/// 覆盖"预览算 DividendIn + EOD 扣减"的完整链路(MU_001 的缺口)。
/// </summary>
[TestClass]
public class DividendEodNoDoubleCountTest
{
private const int SwapTradeId = 9200;
private const long PositionId = 9201;
private const decimal InitialQty = 1000m;
private const decimal RegPer100 = 1.0m; // 每 100 元面值票息 1.0 → qty(1000) 时单期分红 = 1.0×1000/100 = 10
private static readonly DateTime StartDate = new(2026, 1, 5);
#region 内存债券付息数据(payment_date 口径,真实生产 GetBondPayments 读取)
private const string BondUnderlying = "210210.IB";
private static List<BondPayment> BondPayments() => new List<BondPayment>
{
// 付息日 1/6、1/7 各一期
new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 5), payment_date_pl = new DateTime(2026, 1, 6), payment_date = new DateTime(2026, 1, 6), payment_interest = RegPer100 },
new BondPayment { underlyingCode = BondUnderlying, reg_date = new DateTime(2026, 1, 6), payment_date_pl = new DateTime(2026, 1, 7), payment_date = new DateTime(2026, 1, 7), payment_interest = RegPer100 },
};
#endregion
#region Stubs
/// <summary>SwapDealService stub:暴露 GetPreEodDividendSum,注入 EOD 数据(不连库)。</summary>
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);
}
/// <summary>真实 BondPaymentServicepayment_date 口径)seam:仅注入内存 BondPayment 数据,票息计算走生产 GetBondPayments+CalcPayment。</summary>
private sealed class RealBondPaymentService : BondPaymentService
{
private readonly List<BondPayment> _data;
public RealBondPaymentService(List<BondPayment> data, OptUserInfo userInfo) : base(userInfo) { _data = data; }
protected override IQueryable<BondPayment> QueryBondPayments(string underlyingCode)
=> _data.Where(x => x.underlyingCode == underlyingCode).AsQueryable();
}
/// <summary>SwapEodPositionService stub:暴露 UpdateEodPosition/CopyEodPositionCalcBondPayment 桥接真实 BondPaymentServicepayment_date 口径)。</summary>
private sealed class EodSvcStub : TestableSwapEodPositionService
{
private readonly List<BondPayment> _bondPayments;
public EodSvcStub(List<BondPayment> bondPayments) : base(nameof(DividendEodNoDoubleCountTest)) { _bondPayments = bondPayments; }
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
{
// 桥接真实生产口径:GetBondPayments 按 payment_date 过滤 + CalcPayment 累加
var svc = new RealBondPaymentService(_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 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);
public eod_swap_position ExecuteCopyEodPosition(eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate)
=> CopyEodPosition(eod, null, td, valueDate, preSettleDate);
}
#endregion
#region 数据构建
private static trade CreateTrade() => new trade
{
id = SwapTradeId, TradeNumber = "UT-DIV-EOD-001", ClientId = 999999,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = new DateTime(2027, 1, 5), TradeStatus = "确认成交", ValidState = "Valid",
StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY",
OriginalStockEqvNotional = (double)(InitialQty * 1.00m)
};
private static swap_position CreatePosition() => new swap_position
{
id = PositionId, SwapTradeId = SwapTradeId,
PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long,
UnderlyingCode = "210210.IB", ContractSize = 1m,
PosiQuantity = InitialQty, PosiNotionalValue = InitialQty,
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 = SwapTradeId, PositionId = PositionId,
ValueDate = StartDate, PosiQuantity = InitialQty,
PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long,
UnderlyingCode = "210210.IB", 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 SwapEvent(decimal dividendIn, DateTime eventDate) => new swap_flow_event
{
SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.互换,
PositionId = PositionId, Quantity = 0m, DividendIn = dividendIn,
MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m,
EventDate = eventDate, PayDate = eventDate,
DataState = (int)SwapFlowDateStateEnum.完成
};
private static swap_flow_event CloseEvent(decimal qty, decimal dividendIn, DateTime eventDate) => new swap_flow_event
{
SwapTradeId = SwapTradeId, 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(Math.Abs(expected - actual) <= tol, $"{msg}: expected={expected} actual={actual}");
#endregion
/// <summary>
/// 盘中收益互换:DividendIn 由 GetPreEodDividendSum 真实算(读 T-1 EOD)→ 保存 → EOD。
/// 验证:不重复(EOD TdCloseDividend 扣 DividendIn+ 不丢失(当日新计进 PosiDividendSum+ 守恒。
///
/// 序列(StartDate=1/5payment_date 1/6、1/7 各一期,每期 = qty×per100/100 = 10):
/// D1=1/6 无事件 Copy:窗口(1/5,1/6] 命中 payment_date 1/6 → TdPosiDividend=10PosiDividendSum=10
/// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_eventEOD 窗口(1/6,1/7] 命中 payment_date 1/7 → 新计 10 - 实现 10 → PosiDividendSum=10
/// 守恒:全程新计(10+10) - 全程实现(10) = 末尾 PosiDividendSum(10)
/// </summary>
[TestMethod]
public void 盘中收益互换_DividendIn真实算_保存后EOD_不重复不丢失()
{
var eodSvc = new EodSvcStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
// D1=1/6 无事件 EOD
var d1 = new DateTime(2026, 1, 6);
var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, d1, StartDate);
AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum(0+1天×10)");
// D2=1/7 盘中:DividendIn 由生产方法 GetPreEodDividendSum 真实算(读 D1 EOD,当日 EOD 未生成)
var d2 = new DateTime(2026, 1, 7);
var dealSvc = new DealSvcStub(
new List<eod_swap> { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = d1 } },
new List<eod_swap_position> { r1 });
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(SwapTradeId, PositionId, d2);
AssertDecimalEqual(10m, dividendIn, 0.01m, "盘中 DividendIn=GetPreEodDividendSum 读 T-1(D1)=10");
Console.WriteLine($"[盘中预览] DividendIn={dividendIn}(读 T-1 EOD PosiDividendSum={r1.PosiDividendSum}");
// 保存互换事件(DividendIn=真实算出的值,模拟界面点收益互换后保存)
var swapEvent = SwapEvent(dividendIn, d2);
// D2=1/7 EODUpdateEodPosition,真实生产递推)
var r2 = eodSvc.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List<swap_flow_event> { swapEvent });
// 断言:不重复 + 不丢失
AssertDecimalEqual(10m, r2.TdPosiDividend, 0.01m, "D2 当日新计(1天×10)");
AssertDecimalEqual(dividendIn, r2.TdCloseDividend, 0.01m, "D2 TdCloseDividend=互换DividendIn(扣减→不重复累计)");
AssertDecimalEqual(10m, r2.PosiDividendSum, 0.01m, "D2 PosiDividendSum=前日10+新计10-实现10=10(当日新计挂着→不丢失)");
// 守恒:全程新计 - 全程实现 = 末尾 PosiDividendSum
decimal totalNew = r1.TdPosiDividend + r2.TdPosiDividend;
decimal totalRealized = r2.TdCloseDividend;
AssertDecimalEqual(r2.PosiDividendSum, totalNew - totalRealized, 0.01m,
$"守恒:末尾 PosiDividendSum({r2.PosiDividendSum}) = 全程新计({totalNew}) - 全程实现({totalRealized})");
Console.WriteLine($"[EOD 后] TdPosiDividend={r2.TdPosiDividend} TdCloseDividend={r2.TdCloseDividend} PosiDividendSum={r2.PosiDividendSum}");
Console.WriteLine($"结论:互换实现 {dividendIn} 被扣减(不重复);当日新计 {r2.TdPosiDividend} 挂 PosiDividendSum(不丢失)");
}
/// <summary>
/// 付息日当日全平(盘中平仓→收盘持仓 0):按各交易场所规定,不享有付息日当日的分红。
/// </summary>
[TestMethod]
public void 付息日全平_按交易场所规定不享有当日分红()
{
var eodSvc = new EodSvcStub(BondPayments());
var td = CreateTrade();
var position = CreatePosition();
var initialEod = CreateInitialEod();
// D1=1/6 无事件 EOD
var d1 = new DateTime(2026, 1, 6);
var r1 = eodSvc.ExecuteCopyEodPosition(initialEod, td, d1, StartDate);
AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum");
// D2=1/7 盘中全平:DividendIn 由生产方法真实算(读 D1 EOD,当日 EOD 未生成)
var d2 = new DateTime(2026, 1, 7);
var dealSvc = new DealSvcStub(
new List<eod_swap> { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = d1 } },
new List<eod_swap_position> { r1 });
decimal dividendIn = dealSvc.ExposeGetPreEodDividendSum(SwapTradeId, PositionId, d2);
AssertDecimalEqual(10m, dividendIn, 0.01m, "全平 DividendIn=读T-1(D1)=10(漏 D2 当日新计)");
// 全平事件(扣全部持仓)
var closeEvent = CloseEvent(InitialQty, dividendIn, d2);
// D2=1/7 EODUpdateEodPosition,全平→PosiQuantity=0
var r2 = eodSvc.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List<swap_flow_event> { closeEvent });
decimal expectedTotal = r1.PosiDividendSum;
decimal actualGot = dividendIn + r2.PosiDividendSum;
Console.WriteLine($"[付息日全平] 应得(T日前待实现)={expectedTotal}, 实拿(DividendIn+PosiDividendSum)={actualGot}");
Console.WriteLine($"[付息日全平] DividendIn={dividendIn}, EOD:TdPosiDividend={r2.TdPosiDividend} PosiDividendSum={r2.PosiDividendSum} PosiQuantity={r2.PosiQuantity}");
AssertDecimalEqual(expectedTotal, actualGot, 0.01m,
$"实拿应=应得(T日前待实现{expectedTotal}),付息日全平不享有当日分红");
AssertDecimalEqual(0m, r2.TdPosiDividend, 0.01m, "付息日全平 EOD 不计提当日(PosiQuantity=0,正确)");
AssertDecimalEqual(0m, r2.PosiDividendSum, 0.01m, "全平后 PosiDividendSum=0");
}
/// <summary>
/// 【死代码删除的边界规格】脏数据(OriginalStockEqvNotional=null / PosiNetPrice=0)不得让
/// UpdateEodPosition 崩溃,且分红产出与正常数据完全一致。
/// </summary>
[TestMethod]
public void 脏数据边界_死代码涉及字段_不影响EOD分红产出()
{
// 正常数据基准
var eodSvcClean = new EodSvcStub(BondPayments());
var tdClean = CreateTrade();
var positionClean = CreatePosition();
var initialEod = CreateInitialEod();
var d1 = new DateTime(2026, 1, 6);
var d2 = new DateTime(2026, 1, 7);
var r1Clean = eodSvcClean.ExecuteCopyEodPosition(initialEod, tdClean, d1, StartDate);
var r2Clean = eodSvcClean.ExecuteUpdateEodPosition(positionClean, r1Clean, tdClean, d2, d1,
new List<swap_flow_event> { CloseEvent(InitialQty, r1Clean.PosiDividendSum, d2) });
// 脏数据:死代码涉及的两字段置脏(活路径零消费,见方法内 grep 论证)
var eodSvcDirty = new EodSvcStub(BondPayments());
var tdDirty = CreateTrade();
tdDirty.OriginalStockEqvNotional = null; // 死代码 (decimal) 强转崩溃点
var positionDirty = CreatePosition();
positionDirty.PosiNetPrice = 0m; // 死代码除零崩溃点
var r1Dirty = eodSvcDirty.ExecuteCopyEodPosition(initialEod, tdDirty, d1, StartDate);
var r2Dirty = eodSvcDirty.ExecuteUpdateEodPosition(positionDirty, r1Dirty, tdDirty, d2, d1,
new List<swap_flow_event> { CloseEvent(InitialQty, r1Dirty.PosiDividendSum, d2) });
// 脏数据不崩 + 输出与正常数据逐字段一致
AssertDecimalEqual(r2Clean.TdPosiDividend, r2Dirty.TdPosiDividend, 0.0001m, "TdPosiDividend 不受脏字段影响");
AssertDecimalEqual(r2Clean.TdCloseDividend, r2Dirty.TdCloseDividend, 0.0001m, "TdCloseDividend 不受脏字段影响");
AssertDecimalEqual(r2Clean.PosiDividendSum, r2Dirty.PosiDividendSum, 0.0001m, "PosiDividendSum 不受脏字段影响");
AssertDecimalEqual(r2Clean.RealizedDividend, r2Dirty.RealizedDividend, 0.0001m, "RealizedDividend 不受脏字段影响");
Console.WriteLine($"[脏数据边界] 正常={r2Clean.PosiDividendSum} 脏数据={r2Dirty.PosiDividendSum}(应相等且不抛异常)");
}
}
}