using YLErp.Modules.EodModule; namespace YLErp.Modules.SwapModule { /// /// 自动平仓路径(AuotoSwapUnwind → EnrichDividendIn, SwapDealService.cs:1668-1687)多次部分平仓是否多算的实证。 /// EnrichDividendIn 核心:GetBondPayments(td.StartDate, closeDate) × unwindQty(当次平仓量,非剩余持仓)。 /// 本测试直接驱动真实 BondPaymentService.CalcPayment(与 EnrichDividendIn 等价:GetBondPayments 按 reg_date 过滤 + CalcPayment × unwindQty), /// 内存注入 reg_date 数据,不连库。完整 AuotoSwapUnwind 链路因 EnrichDividendIn 直接 new BondPaymentService 查库、无内存 seam 注入点,故用计算核心等价验证。 /// /// 结论验证:多次跨越登记日的部分平仓,每次 × 当次平仓量 → 总额 = 各批按登记日持有 × 平仓量分摊, /// 不自洽多算、不重复计入重叠窗口。 /// (纠正此前"从建仓日重算导致重复计入"的推断:该推断误以为 CalcPayment 乘剩余持仓,实际乘当次 unwindQty。) /// [TestClass] public class AutoUnwindMultiPartialDividendTest { private const string BondCode = "230004.IB"; private static readonly DateTime StartDate = new(2026, 1, 5); private static readonly DateTime Reg1 = new(2026, 5, 15); // 每百元付息 10 private static readonly DateTime Reg2 = new(2026, 6, 15); // 每百元付息 12 private sealed class BridgeBps : BondPaymentService { public BridgeBps(OptUserInfo u) : base(u) { } protected override IQueryable QueryBondPayments(string underlyingCode) => new List { new BondPayment { underlyingCode = BondCode, reg_date = Reg1, payment_date_pl = Reg1, payment_date = Reg1, payment_interest = 10m }, new BondPayment { underlyingCode = BondCode, reg_date = Reg2, payment_date_pl = Reg2, payment_date = Reg2, payment_interest = 12m }, }.Where(x => x.underlyingCode == underlyingCode).AsQueryable(); } // 等价于 EnrichDividendIn 的数值核心:GetBondPayments(StartDate, closeDate) × unwindQty private static decimal EnrichOnce(DateTime closeDate, decimal unwindQty) { var svc = new BridgeBps(OptUserInfo.UnitTestUser); return svc.CalcPayment(BondCode, StartDate, closeDate, unwindQty, 1, 1); } [TestMethod] public void 多次部分平仓_自动路径总额按登记日持仓分摊_不自洽多算() { decimal totalFace = 10_000m; // 总面额 1 万元 decimal halfFace = totalFace / 2m; // 每次平一半 // 第一次 5/20 平一半:窗口(Start,5/20] 仅含 reg1 → 10 × 5000/100 = 500 var d1 = EnrichOnce(new DateTime(2026, 5, 20), halfFace); // 第二次 6/20 平一半:窗口(Start,6/20] 含 reg1+reg2 → (10+12) × 5000/100 = 1100 var d2 = EnrichOnce(new DateTime(2026, 6, 20), halfFace); var total = d1 + d2; // 经济应得(登记日持有规则): // 第一批5000元:5/15持有✓(10)、6/15未持有✗ → 10×5000/100 = 500 // 第二批5000元:5/15持有✓(10)、6/15持有✓(12) → 22×5000/100 = 1100 decimal expected = 10m * halfFace / 100m + (10m + 12m) * halfFace / 100m; Assert.AreEqual(500m, d1, 0.001m, "第一次(5/20)只含 reg1 = 500"); Assert.AreEqual(1100m, d2, 0.001m, "第二次(6/20)含 reg1+reg2 = 1100"); Assert.AreEqual(expected, total, 0.001m, "两次部分平仓总额 = 按登记日持有×平仓量分摊的应得值,重叠窗口不重复计同量(纠正:乘当次 unwindQty 而非剩余持仓)"); // 反证:若手动路径口径(第一次平仓即给全量待实现 = 两次分红×总面额)会多算 decimal manualFullIfFirst = (10m + 12m) * totalFace / 100m; // 2200 Assert.IsTrue(manualFullIfFirst > total, "反证:手动全量落袋口径(2200) > 自动分摊口径(1600),多算方是手动路径而非自动路径"); } } }