From ddf233678d995caedf987571d30d6efe1d2aaa05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Tue, 18 Aug 2026 15:43:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(swap):=20=E6=B7=BB=E5=8A=A0=E5=9F=BA?= =?UTF-8?q?=E9=87=91=E7=8E=B0=E9=87=91=E5=88=86=E7=BA=A2=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 ApplyFundCashDividends 方法处理基金现金分红逻辑 - 现金分红在生效日 EOD 即执行,计入已实现分红字段 - 从盯市盈亏中分离分红金额,避免收益重复计算 - 更新测试用例验证现金分红正确记入已实现分红 - 修正测试方法名称反映实际功能变更 - 完善单元测试验证分红金额计算准确性 --- .../SwapPositionComposeScenarioTest.cs | 23 +++--- .../SwapModule/SwapEodPositionService.cs | 77 +++++++++++++++++++ 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index 8f3b723f..dbdc42db 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -107,6 +107,11 @@ namespace YLErp.Modules.SwapModule flowEvents, dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase), SettleDate); + ApplyFundCashDividends( + positions, + previousEodPositions, + dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase), + SettleDate); } } @@ -327,7 +332,7 @@ namespace YLErp.Modules.SwapModule } [TestMethod] - public void SPC_FUND_002_现金分红_只调整价格不生成分红流水() + public void SPC_FUND_002_现金分红_收盘时记入已实现分红() { var td = CreateTrade(); var position = CreateFloatPosition(1, 1000m); @@ -342,22 +347,20 @@ namespace YLErp.Modules.SwapModule new List(), price: 100m); service.ExDividendInfos.Add(CreateFundCorporateAction(cashAmount: 10m)); - var actual = previousEod.Clone(); - actual.ValueDate = SettleDate; - actual.UnderlyingPrice = 100m; + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate); - service.ExecuteFundCorporateActions( - new[] { actual }, - new[] { previousEod }, - Array.Empty(), - service.ExDividendInfos); + var actual = service.CreatedEodPositions.Single(x => x.PositionId == 1); Assert.AreEqual(1000m, actual.PosiQuantity); Assert.AreEqual(0m, actual.TdChangedQty); Assert.AreEqual(99m, actual.PosiGrossPrice); - Assert.AreEqual(0m, actual.TdPosiDividend); + Assert.AreEqual(1000m, actual.TdPosiDividend); Assert.AreEqual(0m, actual.PosiDividendSum); Assert.AreEqual(99000m, actual.PosiNotionalValue); + Assert.AreEqual(0m, actual.PosiMtmPnL); + Assert.AreEqual(0m, actual.PosiProfitSum); + Assert.AreEqual(1000m, actual.RealizedDividend); + Assert.AreEqual(1000m, actual.RealizedPnl); } [TestMethod] diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index aeddd68a..0fb9afa3 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -591,6 +591,15 @@ namespace YLErp.Modules.SwapModule td, preSettleDate, flowEvents); + // 公司行为 - 分红 + // Fund 现金分红在 EffectiveDate 当日收盘即完成结算: + // TdPosiDividend 展示当日金额,RealizedDividend 累计已实现金额, + // 不把同一笔金额留在 PosiDividendSum 待实现字段中。 + ApplyFundCashDividends( + curEodPosis, + eodPositions, + exDividendByCode, + settleDate); var posiLongNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); var posiShortNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue); var closePosiNotional = curEodPosis.Where(s => s.TdCloseQty > 0).Sum(s => s.TdCloseQty * s.ContractSize * s.PosiGrossPrice); @@ -805,6 +814,74 @@ namespace YLErp.Modules.SwapModule } } + /// + /// 将 Fund 当日现金分红记入 EOD 已实现分红。 + /// GiveCashAmount 按每 10 份金额计算:1000 份、每 10 份派 10,结果为 1000。 + /// 现金分红在生效日 EOD 即执行,因此 PosiDividendSum 不增加本次金额, + /// 同时从除权价格变化产生的 PosiMtmPnL 中剥离,避免收益重复计算。 + /// + protected void ApplyFundCashDividends( + IReadOnlyCollection currentEodPositions, + IReadOnlyCollection previousEodPositions, + IReadOnlyDictionary exDividendByCode, + DateTime settleDate) + { + if (currentEodPositions == null + || previousEodPositions == null + || exDividendByCode == null + || exDividendByCode.Count == 0) + { + return; + } + + var dividendTaxRate = GetDividendTaxRate(); + foreach (var current in currentEodPositions) + { + if (current == null + || current.PosiDirection == 0 + || current.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || string.IsNullOrWhiteSpace(current.UnderlyingCode) + || !exDividendByCode.TryGetValue(current.UnderlyingCode, out var dividendInfo) + || !dividendInfo.EffectiveDate.HasValue + || dividendInfo.EffectiveDate.Value.Date != settleDate.Date) + { + continue; + } + + var previous = previousEodPositions.FirstOrDefault( + x => x != null && x.PositionId == current.PositionId); + var entitlementQuantity = previous?.PosiQuantity ?? current.PosiQuantity; + var directionRatio = DirectionRatio.ReceivePay(current.PosiDirection); + var currentDividend = entitlementQuantity > 0m + ? entitlementQuantity / 10m + * dividendInfo.GiveCashAmount + * (1m - dividendTaxRate) + * directionRatio + : 0m; + + // 当日浮动端分红 + current.TdPosiDividend = RoundMoney(currentDividend); + var previousDividendSum = previous?.PosiDividendSum ?? 0m; + // 浮动端平仓盈亏·分红未实现 = 未实现分红总和 - 当日浮动端平仓盈亏·分红 + current.PosiDividendSum = current.PosiQuantity > 0m + ? RoundMoney(previousDividendSum - current.TdCloseDividend) + : 0m; + // 浮动端平仓盈亏·盯市未实现 = 盯市未实现 - 当日浮动端分红 + // current.PosiMtmPnL = RoundMoney(current.PosiMtmPnL - current.TdPosiDividend); + // 浮动端已实现·分红 = 已实现分红 + 当日浮动端分红 + current.RealizedDividend = RoundMoney(current.RealizedDividend + current.TdPosiDividend); + // 浮动端已实现·盈亏 = 盈亏 + 当日浮动端分红 + current.PosiProfitSum = RoundMoney(MtmCalc.ReturnLegProfitSum( + current.PosiMtmPnL, + current.PosiDividendSum, + current.PosiFeePending)); + SetFloatingRealizedPnl(current); + current.SwapPositionValue = RoundMoney(PositionValueCalc.Calc( + current.InterestProfitSum, + current.PosiProfitSum)); + } + } + /// /// 将一条真实生效日公司行为应用到盘中实时 Fund 浮动腿。 /// 盘中先复制严格早于 valueDate 的 EOD,再调用此方法;因此重复调用时每次都会