Files
zszq-trs/UnitTestProject/Modules/SwapModule/GLMS20260105PartialCloseDividendBugTest.cs
T
hjhan e3c473bafd fix(swap): 分红前部分平仓过的交易,后续平仓/互换分红收益改由 EOD 单一可信源提供
问题:GLMS-20260105-0004 在 2/28 部分平仓 40%、3/2 登记日互换后,3/3 继续平仓/互换时
"浮动端平仓盈亏·分红"误显 -36,160(应为 0)。根因:前端 getDivindIn 用期初全额持仓
×totalInterest 重算分红(-90,400),扣除 consumedDividend(-54,240) 后余 -36,160,
恰为已平仓 40%(登记日前已不持有、不享有该笔分红)部分被重复计入。

方案C:分红收益改由后端读上一收盘日 eod_swap_position.PosiDividendSum 提供(单一可信源),
前端不再自算。EOD 的 PosiDividendSum 已按"实际持仓递推 + 当日实现扣除"准确算出待实现分红,
3/2 互换后为 0。因手动平仓/互换后端不重算 DividendIn、直接落库前端值,后端 Init 阶段给出
正确值可同时修正展示与落库。

改动:
- SwapDealService 新增 GetPreEodDividendSum(复用 GetUnwindInterests:624 的上一 EOD 日期推导
  + SwapEodPositionService.GetPreEodPositions),protected virtual 便于测试覆写、无 EOD 返回 0
- InitUnwind/InitIncome 的 floatEvent.DividendIn 从硬编码 0 改为 GetPreEodDividendSum 返回值
- unwindSwapTrade.js/incomeSwapTrade.js getDivindIn 删除 GetBondPayMentInterest 调用与全部自算,
  保留后端 DividendIn 值、DividendPending=0
- GetBondPayMentInterest 加废弃文档注释(保留接口供历史调用,consumedDividend 无日期过滤隐患随废弃消解)

测试:GLMS20260105PartialCloseDividendBugTest 连真实库验收——3/3 平仓/互换 DividendIn=0(修复前
-36,160)、无 EOD 防御返回 0。全量构建 0 错误。
2026-08-06 19:09:45 +08:00

159 lines
9.5 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 Microsoft.VisualStudio.TestTools.UnitTesting;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 【绿灯验收】国联民生-期间结算事件验证-分红前部分平仓过的交易,平仓与分红结束并收盘后,
/// 继续平仓 / 互换时"浮动端平仓盈亏·分红(DividendIn)"应为 0(修复方案C:后端读 EOD 单一可信源)。
/// ============================================================================
/// 参考交易:GLMS-20260105-0004(测试库 id=1921,标的 230004.IB
/// 场景时间线(库内真实数据):
/// 2026-01-05 开仓,期初持仓 50,000,000(支付方向 Direction=2,多头 PositionType=1
/// 2026-02-28 部分平仓 40%20,000,000),剩余 30,000,000;该日早于 3/2 登记日,
/// 已平仓的 40% 不享有 3/2 付息 → 平仓事件 DividendIn=0(正确)
/// 2026-03-02 债券期间付息登记日;当日自动互换(EventType=4),按【剩余持仓 30,000,000】
/// 实现分红 DividendIn=-54,240=30,000,000 × 0.001808);EOD PosiDividendSum=0(全实现)
/// 2026-03-03 收盘后对该交易继续平仓/互换
///
/// 修复前(Bug):前端 getDivindIn 用期初全额持仓(50M)×totalInterest 算出 -90,400
/// 扣除 consumedDividend(-54,240) 得 remainDividend=-36,160 展示 → 错误。
/// -36,160 恰=已平仓40%(20M)×单位付息(0.001808)×支付方向(-1),即把"登记日前已平仓、
/// 不享有该笔分红"的部分重复计入。
///
/// 修复后(方案C):后端 InitUnwind/InitIncome 经 GetPreEodDividendSum 读上一收盘日
/// eod_swap_position.PosiDividendSum。3/2 互换后该值=0 → DividendIn=0(正确)。
/// 前端 getDivindIn 不再自算,直接用后端值。
/// ============================================================================
/// 本测试连真实测试库,调用真实 SwapDealService.GetPreEodDividendSum 验证修复后 DividendIn=0。
/// 连不上库时 Inconclusive 跳过(CI 无 DB 环境不挡)。
/// </summary>
[TestClass]
public class GLMS20260105PartialCloseDividendBugTest
{
private const string TradeNumber = "GLMS-20260105-0004";
// 3/3 继续平仓/互换的业务日期;上一收盘日为 3/2(EOD 已生成,PosiDividendSum=0
private static readonly DateTime DealDate0303 = new(2026, 3, 3);
/// <summary>
/// 连真实库的 SwapDealService 子类,仅用于暴露 protected GetPreEodDividendSum 供单测调用。
/// 不 override 任何 seam → DbContext 走真实 YLContext(与 DbContextFactory.GetYLDbContext() 同库)。
/// </summary>
private sealed class RealDbSwapDealService : SwapDealService
{
public RealDbSwapDealService() : base(OptUserInfo.UnitTestUser) { }
public decimal ExposeGetPreEodDividendSum(int tradeId, long positionId, DateTime dealDate)
=> GetPreEodDividendSum(tradeId, positionId, dealDate);
}
[TestMethod]
[TestCategory("DBRecording")]
public void Green_3_3_Unwind_DividendIn_FromEOD_Equals_Zero()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}"); return; }
try
{
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
if (td == null) { Assert.Inconclusive($"测试库不存在交易 {TradeNumber}"); return; }
// 浮动腿持仓:非初始、UnderlyingCode 非空(当前剩余 30,000,000
var positions = db.swap_position.Where(x => x.SwapTradeId == td.id && !x.Invalid).ToList();
var floatLeg = positions.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial);
Assert.IsNotNull(floatLeg, "未找到浮动腿持仓(非初始、UnderlyingCode 非空)");
// ---- 调用真实 GetPreEodDividendSum(方案C 修复核心)----
var service = new RealDbSwapDealService();
decimal dividendIn = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, DealDate0303);
// ---- 绿灯断言:3/2 互换后 EOD PosiDividendSum=0 → DividendIn=0 ----
Assert.AreEqual(0m, dividendIn, 0.01m,
$"[绿灯·方案C验收] 3/3平仓/互换 DividendIn 期望=0(3/2互换后剩余持仓30M的分红已全部实现," +
$"EOD PosiDividendSum=0)。实际={dividendIn}。修复前该值为 -36,160(期初持仓×totalInterest 重算误计入已平仓40%)。");
// ---- 健全性:上一 EOD 确为 3/2,且 PosiDividendSum=0 ----
var lastEod = db.eod_swap_position
.Where(x => x.SwapTradeId == td.id && !x.Invalid && x.ValueDate < DealDate0303 && x.PositionId == floatLeg.PositionId)
.OrderByDescending(x => x.ValueDate).FirstOrDefault();
Assert.IsNotNull(lastEod, "应存在 3/2 的 EOD 持仓记录");
Assert.AreEqual(new DateTime(2026, 3, 2), lastEod.ValueDate, "上一收盘日应为 3/2");
Assert.AreEqual(0m, lastEod.PosiDividendSum, 0.01m,
$"3/2 EOD PosiDividendSum 应=0(当日 TdPosiDividend={lastEod.TdPosiDividend} 全额由互换 TdCloseDividend={lastEod.TdCloseDividend} 实现)");
Assert.AreEqual(30_000_000m, lastEod.PosiQuantity, "3/2 剩余持仓应为 30,000,0002/28已平仓40%");
}
finally { db?.Dispose(); }
}
/// <summary>
/// 互换页(InitIncome)与平仓页(InitUnwind)读同一 EOD:同 position、同 dealDate
/// GetPreEodDividendSum 返回值必然一致。本测试明确覆盖"点击收益互换同理"路径
/// (用户反馈:平仓页 -36,160,点击收益互换同样错误)。
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Green_3_3_Income_DividendIn_FromEOD_Equals_Zero()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}"); return; }
try
{
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
if (td == null) { Assert.Inconclusive($"测试库不存在交易 {TradeNumber}"); return; }
var floatLeg = db.swap_position
.Where(x => x.SwapTradeId == td.id && !x.Invalid && !x.IsInitial && x.UnderlyingCode != null && x.UnderlyingCode != "")
.FirstOrDefault();
Assert.IsNotNull(floatLeg, "未找到浮动腿持仓");
// InitIncome 与 InitUnwind 调用 GetPreEodDividendSum 的入参一致 → 结果一致
var service = new RealDbSwapDealService();
decimal incomeDividendIn = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, DealDate0303);
Assert.AreEqual(0m, incomeDividendIn, 0.01m,
$"[互换页·方案C验收] 3/3收益互换 DividendIn 期望=0,实际={incomeDividendIn}。" +
$"InitIncome 与 InitUnwind 共用 GetPreEodDividendSum,应返回同一 EOD 值。");
}
finally { db?.Dispose(); }
}
/// <summary>
/// 防御性:dealDate 早于任何 EOD(如交易首日尚未收盘)时,GetPreEodDividendSum 应返回 0
/// 不抛异常。覆盖"无 EOD 记录"边界——fallback 到 dealDate.AddDays(-1) 后查无数据 → 0。
/// </summary>
[TestMethod]
[TestCategory("DBRecording")]
public void Green_NoEodBeforeDealDate_Returns_Zero_WithoutThrowing()
{
YLContext db;
try { db = DbContextFactory.GetYLDbContext(); }
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}"); return; }
try
{
var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber);
if (td == null) { Assert.Inconclusive($"测试库不存在交易 {TradeNumber}"); return; }
var floatLeg = db.swap_position
.Where(x => x.SwapTradeId == td.id && !x.Invalid && !x.IsInitial && x.UnderlyingCode != null && x.UnderlyingCode != "")
.FirstOrDefault();
Assert.IsNotNull(floatLeg, "未找到浮动腿持仓");
var service = new RealDbSwapDealService();
// dealDate = 交易开始日前一天,确保无任何 EOD 满足 ValueDate < dealDate
var preStartDate = td.StartDate.Value.AddDays(-1);
decimal dividendIn = service.ExposeGetPreEodDividendSum(td.id, floatLeg.PositionId, preStartDate);
Assert.AreEqual(0m, dividendIn, 0.01m,
$"[防御] dealDate({preStartDate:yyyy-MM-dd})早于所有 EOD 时,GetPreEodDividendSum 应返回 0,实际={dividendIn}。" +
$"与历史 InitUnwind/InitIncome 中 DividendIn=0 行为一致,不抛异常。");
}
finally { db?.Dispose(); }
}
}
}