Files
zszq-trs/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs
T
hjhan d9a55fd11a test(dividend): 补全平特征化测试——登记日盘中全平当日分红丢失10(已知缺陷)
全平端到端测试(DividendIn 由 GetPreEodDividendSum 真实算)暴露:登记日盘中全平,当日分红丢失 10。
根因:①盘中 DividendIn 读 T-1(当日 EOD 未生成,漏当日新计);②EOD 全平 PosiQuantity=0 → TdPosiDividend=0(不计提当日)+ PosiDividendSum=0。当日分红既没进 DividendIn、也没进 PosiDividendSum,彻底丢失。

与收益互换(不扣持仓)对比:收益互换不丢失(当日新计挂 PosiDividendSum);全平丢失(扣持仓后 EOD 不计提)。
特征化断言 lost=10(锁定现状,CI 绿),注释待修复方向,修复后改回断言 lost=0。
2026-08-14 10:35:05 +08:00

232 lines
14 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.DBModels;
using YLErp.DBModels.Enums;
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 DailyRatePerUnit = 0.01m; // 每单位每天 0.01,便于手算
private static readonly DateTime StartDate = new(2026, 1, 5);
#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>SwapEodPositionService stub:暴露 UpdateEodPosition/CopyEodPosition + 线性 CalcBondPayment。</summary>
private sealed class EodSvcStub : TestableSwapEodPositionService
{
public EodSvcStub() : base(nameof(DividendEodNoDoubleCountTest)) { }
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
{
int days = Math.Max(0, (int)(toDate - fromDate).TotalDays);
return DailyRatePerUnit * days * 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/5,每日 0.01×1000=10):
/// D1=1/6 无事件 CopyPosiDividendSum = 0 + 10 = 10
/// D2=1/7 盘中互换:GetPreEodDividendSum(读 D1) → DividendIn=10;保存 swap_eventEOD:新计 10 - 实现 10 → PosiDividendSum=10
/// 守恒:全程新计(10+10) - 全程实现(10) = 末尾 PosiDividendSum(10)
/// </summary>
[TestMethod]
public void 盘中收益互换_DividendIn真实算_保存后EOD_不重复不丢失()
{
var eodSvc = new EodSvcStub();
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>
/// 盘中全平(扣持仓→PosiQuantity=0):验证登记日当日分红是否丢失。
///
/// 全平 vs 收益互换的关键区别:全平扣持仓,EOD 时 PosiQuantity=0 → 当日新计 TdPosiDividend 不计提(:1834 守卫)
/// + PosiDividendSum 归 0:1856 else)。若盘中 DividendIn 读 T-1(漏当日新计),则当日分红
/// 既没进 DividendIn、也没进 PosiDividendSum → 丢失。本测试暴露这个风险。
///
/// 序列(StartDate=1/5,每日 0.01×1000=10):
/// D1=1/6 无事件 CopyPosiDividendSum=10
/// D2=1/7 盘中全平:DividendIn=GetPreEodDividendSum(读 D1)=10(漏 D2 当日);EODPosiQuantity=0→TdPosiDividend=0, PosiDividendSum=0
/// 应得(登记日持仓享当日)= D1累计(10) + D2当日(10) = 20;实拿 = DividendIn(10) + PosiDividendSum(0) = 10 → 丢失 10
/// </summary>
[TestMethod]
public void 特征化_登记日全平_当日分红丢失10_已知缺陷待修复()
{
var eodSvc = new EodSvcStub();
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 });
// 登记日持仓应享当日分红:D1累计 + D2当日(若持仓>0 本应计提 = 1天×0.01×1000=10
decimal d2AccrualIfHeld = DailyRatePerUnit * 1 * InitialQty;
decimal expectedTotal = r1.PosiDividendSum + d2AccrualIfHeld; // 应得 = 10 + 10 = 20
decimal actualGot = dividendIn + r2.PosiDividendSum; // 实拿 = DividendIn + 末尾挂着
decimal lost = expectedTotal - actualGot;
Console.WriteLine($"[全平] D1累计={r1.PosiDividendSum}, D2当日(若计提)={d2AccrualIfHeld}, 应得={expectedTotal}");
Console.WriteLine($"[全平] DividendIn={dividendIn}, EOD:TdPosiDividend={r2.TdPosiDividend} PosiDividendSum={r2.PosiDividendSum} PosiQuantity={r2.PosiQuantity}");
Console.WriteLine($"[全平] 实拿={actualGot}, 丢失={lost}");
// 【特征化·已知缺陷】登记日盘中全平,当日分红丢失 10(D2当日新计既没进 DividendIn 也没进 PosiDividendSum)。
// 根因:①盘中 DividendIn=GetPreEodDividendSum 读 T-1(当日 EOD 未生成,漏当日新计);
// ②EOD 全平 PosiQuantity=0 → TdPosiDividend=0:1834 不计提当日)+ PosiDividendSum=0:1856)。
// 待修复:GetPreEodDividendSum 当日 EOD 不存在时补算当日分红,或全平 EOD 按登记日持仓计提。
// 修复后改回 AssertDecimalEqual(0m, lost, ...)(不丢失)。
AssertDecimalEqual(10m, lost, 0.01m,
$"【已知缺陷】登记日盘中全平当日分红丢失={lost}(应得{expectedTotal}, 实拿{actualGot})。待修复。");
}
}
}