diff --git a/UnitTestProject/Modules/EodModule/BondPaymentServiceCalculationTest.cs b/UnitTestProject/Modules/EodModule/BondPaymentServiceCalculationTest.cs new file mode 100644 index 00000000..822de107 --- /dev/null +++ b/UnitTestProject/Modules/EodModule/BondPaymentServiceCalculationTest.cs @@ -0,0 +1,51 @@ +using YLErp.DBModels; +namespace YLErp.Modules.EodModule +{ + [TestClass] + public class BondPaymentServiceCalculationTest + { + private static BondPaymentService CreateService() + { + return new BondPaymentService( + new OptUserInfo(0, nameof(BondPaymentServiceCalculationTest), OptUserFrom.UnitTest)); + } + + [TestMethod] + public void CalcPayment_BondCoupon_KeepsPerHundredScale() + { + var payments = new List + { + new BondPayment { payment_interest = 1m } + }; + + // 债券票息 1 表示每 100 元面值付 1 元:1 * 1000 / 100 = 10。 + var actual = CreateService().CalcPayment( + payments, + qty: 1000m, + longRatio: 1m, + payDirection: 1m); + + Assert.AreEqual(10m, actual); + } + + [TestMethod] + public void CalcPayment_StockOrFundDividend_DoesNotApplyBondScale() + { + var payments = new List + { + new BondPayment { payment_interest = 10m } + }; + + // GiveCashAmount=10(每 10 份派 10)时,payment_interest 直接存 10; + // 持仓 1000 份的现金分红 = 10 * 1000 / 10 = 1000,不能再套债券报价的 /100 换算。 + var actual = CreateService().CalcPayment( + payments, + qty: 1000m, + longRatio: 1m, + payDirection: 1m, + useBondPriceScale: false); + + Assert.AreEqual(1000m, actual); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs index 7e755187..7e202a38 100644 --- a/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs +++ b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs @@ -113,7 +113,7 @@ namespace YLErp.Modules.SwapModule } [TestMethod] - public void OperationHistory_FiltersPendingCorporateActionOnly() + public void OperationHistory_PreservesPendingCorporateActionForAudit() { var info = CreateAction(80, ConsGlobal.InstrumentType.Stock); var pendingData = SwapEodPositionService.BuildCorporateActionEventData( @@ -133,12 +133,12 @@ namespace YLErp.Modules.SwapModule new swap_event { id = 3, EventType = (int)SwapEventTypeEnum.互换, EventData = "{}" } }; - var visible = SwapEventService.FilterOperationHistory(events); - - Assert.AreEqual(2, visible.Count); - CollectionAssert.DoesNotContain(visible.Select(x => x.id).ToList(), 1L); - CollectionAssert.Contains(visible.Select(x => x.id).ToList(), 2L); - CollectionAssert.Contains(visible.Select(x => x.id).ToList(), 3L); + // 操作历史不再隐藏登记日待生效事件;Applied=false 是事件状态,不是展示过滤条件。 + Assert.AreEqual(3, events.Count); + Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[0], out var pendingSnapshot)); + Assert.IsFalse(pendingSnapshot.Applied); + Assert.IsTrue(SwapEventService.TryDeserializeCorporateActionEventData(events[1], out var appliedSnapshot)); + Assert.IsTrue(appliedSnapshot.Applied); } [TestMethod] @@ -158,7 +158,7 @@ namespace YLErp.Modules.SwapModule var info = CreateAction(81, ConsGlobal.InstrumentType.Stock); info.GiveShareAmount = 10m; - var applied = SwapEodPositionService.ApplyFundCorporateActionToPosition( + var applied = SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, diff --git a/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs index 8531e661..1f3a6d93 100644 --- a/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs +++ b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs @@ -77,6 +77,38 @@ namespace YLErp.Modules.SwapModule "未恢复基线时不得擅自改写前端请求,沿用既有当日实时流程"); } + [TestMethod] + public void FCA_UW_008_股票TRS平仓恢复有效Eod基线() + { + var realtime = CreateRealtimeFundPosition(); + realtime.UnderlyingCode = "STOCK.TEST"; + realtime.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock; + realtime.PosiQuantity = 1000m; + realtime.PosiGrossPrice = 100m; + realtime.PosiNetPrice = 100m; + realtime.PosiNetFeePrice = 100m; + realtime.PosiNetNoFeePrice = 100m; + realtime.PosiNotionalValue = 100000m; + + var eod = CreateEod(ExDate, 2000m, 50m); + eod.UnderlyingCode = "STOCK.TEST"; + eod.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock; + + var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false); + var unwindData = CreateFullCloseUnwindData(); + unwindData.ValueDate = ExDate; + unwindData.UnwindDate = ExDate.AddDays(1); + + Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate)); + Assert.AreEqual(2000m, realtime.PosiQuantity, + "Stock TRS 生效日盘中平仓应使用有效 EOD 数量,不能继续使用除权前实时数量"); + Assert.AreEqual(50m, realtime.PosiGrossPrice, + "Stock TRS 生效日盘中平仓应使用有效 EOD 价格"); + Assert.AreEqual(2000m, unwindData.PositionQty); + Assert.AreEqual(2000m, unwindData.CloseQty); + Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice); + } + [TestMethod] public void FCA_UW_005_生效日盘中恢复前一Eod后再套除权() { diff --git a/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs b/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs index 022ad4df..2cd6ba1f 100644 --- a/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs +++ b/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs @@ -14,7 +14,7 @@ namespace YLErp.Modules.SwapModule var position = CreateFundPosition(); var info = CreateCorporateAction(split: 10m); - Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, 0m)); Assert.AreEqual(1000m, position.PosiQuantity); @@ -27,7 +27,7 @@ namespace YLErp.Modules.SwapModule var position = CreateFundPosition(); var info = CreateCorporateAction(split: 0.1m); - Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, 0m)); Assert.AreEqual(10m, position.PosiQuantity); @@ -40,7 +40,7 @@ namespace YLErp.Modules.SwapModule var position = CreateFundPosition(); var info = CreateCorporateAction(giveShare: 10m, split: null); - Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, 0m)); Assert.AreEqual(200m, position.PosiQuantity); @@ -54,7 +54,7 @@ namespace YLErp.Modules.SwapModule var info = CreateCorporateAction(giveShare: 5m, split: 2m); // (1 + 5 / 10) * 2 = 3:100 份/100 元变为 300 份/约 33.333333333 元。 - Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, 0m)); Assert.AreEqual(300m, position.PosiQuantity); @@ -67,7 +67,7 @@ namespace YLErp.Modules.SwapModule var position = CreateFundPosition(); var info = CreateCorporateAction(cash: 10m); - Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, 0m)); Assert.AreEqual(100m, position.PosiQuantity); @@ -82,7 +82,7 @@ namespace YLErp.Modules.SwapModule rationedSharesAmount: 1m, rationedSharesPrice: 50m); - Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + Assert.IsTrue(SwapEodPositionService.ApplyCorporateActionToPosition( position, info, 100m, 0m)); // Excel L-N:L=(100*10+1*50)/(10+1)=95.4545...,M=100/L; @@ -97,7 +97,7 @@ namespace YLErp.Modules.SwapModule var info = CreateCorporateAction(split: 0m); Assert.ThrowsException(() => - SwapEodPositionService.ApplyFundCorporateActionToPosition( + SwapEodPositionService.ApplyCorporateActionToPosition( CreateFundPosition(), info, 100m, 0m)); } @@ -107,7 +107,7 @@ namespace YLErp.Modules.SwapModule var info = CreateCorporateAction(split: -1m); Assert.ThrowsException(() => - SwapEodPositionService.ApplyFundCorporateActionToPosition( + SwapEodPositionService.ApplyCorporateActionToPosition( CreateFundPosition(), info, 100m, 0m)); } diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index 9b8e5677..a750ccec 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -97,19 +97,10 @@ namespace YLErp.Modules.SwapModule public void ExecuteFundCorporateActions( IReadOnlyCollection positions, - IReadOnlyCollection previousEodPositions, - IReadOnlyCollection flowEvents, IReadOnlyCollection dividendInfos) { - ApplyFundCorporateActions( + ApplyCorporateActions( positions, - previousEodPositions, - flowEvents, - dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase), - SettleDate); - ApplyFundCashDividends( - positions, - previousEodPositions, dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase), SettleDate); } @@ -315,8 +306,6 @@ namespace YLErp.Modules.SwapModule service.ExecuteFundCorporateActions( new[] { actual }, - new[] { previousEod }, - Array.Empty(), service.ExDividendInfos); Assert.AreEqual(2000m, actual.PosiQuantity); @@ -381,25 +370,24 @@ namespace YLErp.Modules.SwapModule new List(), price: 100m); service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); - var todayEod = previousEod.Clone(); - todayEod.ValueDate = SettleDate; - todayEod.UnderlyingPrice = 100m; + // 生产重收盘每次都会从上一日 EOD clone 出新的当日基线,再应用一次公司行为; + // 底层 ApplyCorporateActions 只负责处理调用方提供的未调整基线,不再承担恢复旧基线的测试兼容职责。 + var firstRunEod = previousEod.Clone(); + firstRunEod.ValueDate = SettleDate; + firstRunEod.UnderlyingPrice = 100m; + service.ExecuteFundCorporateActions(new[] { firstRunEod }, service.ExDividendInfos); - service.ExecuteFundCorporateActions( - new[] { todayEod }, - new[] { previousEod }, - Array.Empty(), - service.ExDividendInfos); - service.ExecuteFundCorporateActions( - new[] { todayEod }, - new[] { previousEod }, - Array.Empty(), - service.ExDividendInfos); + var rerunEod = previousEod.Clone(); + rerunEod.ValueDate = SettleDate; + rerunEod.UnderlyingPrice = 100m; + service.ExecuteFundCorporateActions(new[] { rerunEod }, service.ExDividendInfos); - Assert.AreEqual(2000m, todayEod.PosiQuantity); - Assert.AreEqual(1000m, todayEod.TdChangedQty); - Assert.AreEqual(50m, todayEod.PosiGrossPrice); - Assert.AreEqual(100000m, todayEod.PosiNotionalValue); + Assert.AreEqual(2000m, firstRunEod.PosiQuantity); + Assert.AreEqual(1000m, firstRunEod.TdChangedQty); + Assert.AreEqual(50m, firstRunEod.PosiGrossPrice); + Assert.AreEqual(100000m, firstRunEod.PosiNotionalValue); + Assert.AreEqual(firstRunEod.PosiQuantity, rerunEod.PosiQuantity); + Assert.AreEqual(firstRunEod.PosiGrossPrice, rerunEod.PosiGrossPrice); } [TestMethod] @@ -427,8 +415,6 @@ namespace YLErp.Modules.SwapModule service.ExecuteFundCorporateActions( new[] { actual }, - new[] { previousEod }, - Array.Empty(), service.ExDividendInfos); Assert.AreEqual(1000m, actual.PosiQuantity); @@ -559,8 +545,6 @@ namespace YLErp.Modules.SwapModule service.ExecuteFundCorporateActions( new[] { actual }, - new[] { previousEod }, - Array.Empty(), service.ExDividendInfos); Assert.AreEqual(10m, actual.PosiQuantity, diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs index 6663355a..450c52d7 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs @@ -65,8 +65,6 @@ namespace YLErp.Modules.SwapModule decimal fallbackPrice) => fallbackPrice; - protected override decimal GetFundDividendTaxRate() => 0m; - public bool RestoreEffectiveFundPositionForTest(UnwindData unwindData, DateTime valueDate) => TryRestoreEffectiveFundPosition(unwindData, valueDate); diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs index 1809123e..6c83858e 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs @@ -84,15 +84,6 @@ namespace YLErp.Modules.SwapModule return 1.0; // 本币,汇率=1 } - protected override List FindExDividendInfos(DateTime settleDate) - { - return ExDividendInfos - .Where(x => x.ValidStatus - && x.EffectiveDate.HasValue - && x.EffectiveDate.Value.Date == settleDate.Date) - .ToList(); - } - protected override List FindCorporateActionInfos(DateTime settleDate) { return ExDividendInfos @@ -102,15 +93,6 @@ namespace YLErp.Modules.SwapModule .ToList(); } - protected override List FindRegistrationExDividendInfos(DateTime settleDate) - { - return ExDividendInfos - .Where(x => x.ValidStatus - && x.ExDividendDate.HasValue - && x.ExDividendDate.Value.Date == settleDate.Date) - .ToList(); - } - protected override List FindCorporateActionEvents(int swapTradeId) { return CorporateActionEvents @@ -124,11 +106,6 @@ namespace YLErp.Modules.SwapModule decimal fallbackPrice) => fallbackPrice; - protected override decimal GetDividendTaxRate() - { - return 0m; - } - protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) { ClientCashCalls.Add((amount, action)); diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs index 8661bdee..c4b56206 100644 --- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs +++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs @@ -1,4 +1,4 @@ -using BaseOUDAL; +using BaseOUDAL; using DocumentFormat.OpenXml.Bibliography; using ExcelDataReader.Log; using YLErp.DBModels; @@ -99,6 +99,52 @@ namespace YLErp.Modules.EodModule public List GetBondPayments(string underlyingCode, DateTime startDate, DateTime endDate) { var result = DbContext.bondPayment.Where(x => x.underlyingCode == underlyingCode && x.payment_date > startDate && x.payment_date <= endDate).AsNoTracking().ToList(); + + // 让 Copy/Update EOD 始终只依赖 BondPaymentService,而不必在收盘链路直接累加 ex_dividend_info。 + // 口径约定:bond_payment_info.payment_interest 对 Stock/Fund 统一按“每 10 份派现金额”存储, + // 即直接存 ex_dividend_info.GiveCashAmount 原值,不做 /10; + // 最后的 /10 由 CalcPayment 非债券分支完成。 + // 去重:镜像任务完成后的正式记录带 jsid = -dividend.id;此处先按该负号标记, + // 或用“同一实际付息日 + 同一派现金额”兜底去重,避免镜像完成后重复计息。 + var corporatePayments = (from dividend in DbContext.ex_dividend_info.AsNoTracking() + join underlying in DbContext.underlying_manager.AsNoTracking() + on dividend.UnderlyingCode equals underlying.UnderlyingCode + where dividend.ValidStatus + && dividend.EffectiveDate.HasValue + && dividend.EffectiveDate.Value > startDate + && dividend.EffectiveDate.Value <= endDate + && dividend.GiveCashAmount != 0 + && dividend.UnderlyingCode == underlyingCode + && (underlying.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Stock + || underlying.UnderlyingInstrumentType == ConsGlobal.InstrumentType.Fund) + select dividend).ToList(); + foreach (var dividend in corporatePayments) + { + // 按“每 10 份派现金额”口径,直接存 GiveCashAmount 原值,与同步任务/CalcPayment 保持一致。 + var paymentInterest = dividend.GiveCashAmount; + var hasMirroredPayment = result.Any(payment => + payment.jsid == -dividend.id + || (payment.payment_date.HasValue + && payment.payment_date.Value.Date == dividend.EffectiveDate.Value.Date + && payment.payment_interest == paymentInterest)); + if (hasMirroredPayment) + { + continue; + } + + result.Add(new BondPayment + { + underlyingCode = dividend.UnderlyingCode, + payment_date_pl = dividend.EffectiveDate, + payment_date = dividend.EffectiveDate, + payment_interest = paymentInterest, + paying_price = paymentInterest, + channel_source = ExDividendDataSources.Manual, + jsid = -dividend.id, + create_time = dividend.OptDate, + update_time = dividend.OptDate + }); + } return result; } @@ -142,8 +188,8 @@ namespace YLErp.Modules.EodModule /// /// 是否按债券报价的百分比口径换算。债券的 payment_interest 是每 100 元面值的票息, /// 需要继续通过 BondPriceConverter 转成入库金额;Fund/Stock 的公司行为现金分红 - /// 在 bond_payment_info 中按每 10 份存储,payment_interest * qty 已经是实际现金, - /// 不能再做一次 /100。默认 true 是为了保持所有历史债券调用方的原有口径。 + /// 在 bond_payment_info 中按“每 10 份派现金额”存储,payment_interest * qty / 10 才是实际现金, + /// 不能再套债券的 /100。默认 true 是为了保持所有历史债券调用方的原有口径。 /// /// public decimal CalcPayment( @@ -156,11 +202,12 @@ namespace YLErp.Modules.EodModule var interest = payments.Sum(s => s.payment_interest ?? 0); var paymentAmount = interest * qty; // 债券:interest 为每 100 元面值的票息,×qty 后需 ÷100 转为实际金额。 - // Fund/Stock 公司行为:interest 已由【同步任务】写成 GiveCashAmount/10, - // ×qty 就是“每 10 份派现额 × 持仓份额”,必须保留原金额,不能套债券的 /100。 + // Fund/Stock 公司行为:payment_interest 存的是“每 10 份派现金额”(GiveCashAmount 原值), + // interest × qty 得到“每 10 份派现额 × 持仓份数”,需再 ÷10 才是实际现金; + // 既不能套债券的 /100,也不能直接返回 paymentAmount(那样会放大 10 倍)。 var actualAmount = useBondPriceScale ? BondPriceConverter.ToStorage(paymentAmount) - : paymentAmount; + : paymentAmount / 10; return actualAmount * longRatio * payDirection; } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index be443144..4b9daa62 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -285,7 +285,7 @@ namespace YLErp.Modules.SwapModule return query.FirstOrDefault(); } - /// 查询 valueDate 当日已经生效的最近有效 Fund EOD。 + /// 查询 valueDate 当日已经生效的最近有效 Stock/Fund EOD。 protected virtual eod_swap_position FindLatestFundEodPosition( int tradeId, long positionId, @@ -296,7 +296,7 @@ namespace YLErp.Modules.SwapModule } /// - /// 查询 valueDate 当天真正生效的 Fund 公司行为。 + /// 查询 valueDate 当天真正生效的 Stock/Fund 公司行为。 /// ExDividendDate 只是登记日,盘中基线不能按登记日提前切换;只有 /// EffectiveDate == valueDate 时才把上一 EOD 的 Q/P 转成当日 BOD 的除权后 Q/P。 /// @@ -311,7 +311,7 @@ namespace YLErp.Modules.SwapModule } /// - /// 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。 + /// Stock/Fund 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。 /// 测试可用 EOD 快照价格作为回退值;生产从登记日行情表取真实收盘价。 /// protected virtual decimal GetFundCorporateActionClosePrice( @@ -328,10 +328,6 @@ namespace YLErp.Modules.SwapModule return Convert.ToDecimal(closePrice); } - /// 读取 Fund 现金分红税率;单元测试可固定为 0,避免依赖系统日期配置。 - protected virtual decimal GetFundDividendTaxRate() - => new DividendService(this).GetDividendTaxRateDecimal(); - /// /// 判断最新 EOD 之后是否已有同一浮动腿的完成流水。若有,说明当日实时持仓已发生部分平仓/互换, /// 不能再把较早 EOD 的数量覆盖回来,否则会抹掉当日成交结果。 @@ -351,22 +347,22 @@ namespace YLErp.Modules.SwapModule } /// - /// 恢复实时 Fund 浮动腿到截至指定日有效的 EOD 基线。 + /// 恢复实时 Stock/Fund 浮动腿到截至指定日有效的 EOD 基线。 /// 这是唯一允许把 EOD 公司行为结果带入盘中平仓的入口:10 送 10 后 EOD 是 2000 份/50 /// 时,下一日直接使用 2000/50,不再把前端可能传入的 1000/100 或已除权价格重复套系数。 - /// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Fund、无 EOD - /// 和固定/利息腿均返回 false,沿用原逻辑。 + /// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Stock/Fund、无 + /// EOD 和固定/利息腿均返回 false,沿用原逻辑。 /// protected virtual bool TryRestoreEffectiveFundPosition( swap_position position, DateTime valueDate) { - // 只对收取方向的 Fund 浮动腿恢复 EOD;固定腿、利息腿和支付方向不应被公司行为改写。 + // 只对收取方向的 Stock/Fund 浮动腿恢复 EOD;固定腿、利息腿和支付方向不应被公司行为改写。 // 无历史 EOD 或最新 EOD 后已有完成流水时返回 false,由调用方保持实时持仓原值, // 不伪造一份快照,也不把较早的 2000 份/50 覆盖掉当日已经部分平仓后的实时数量。 if (position == null || position.PosiDirection <= 0 - || position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund) + || !SwapEodPositionService.IsCorporateActionInstrument(position.UnderlyingInstrumentType)) { return false; } @@ -405,15 +401,16 @@ namespace YLErp.Modules.SwapModule if (closePrice <= 0) { throw new ServiceException( - $"Fund 标的【{position.UnderlyingCode}】登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); + $"Stock/Fund 标的【{position.UnderlyingCode}】登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); } - var dividendTaxRate = GetFundDividendTaxRate(); - SwapEodPositionService.ApplyFundCorporateActionToPosition( + // TODO: 现金模式不使用税率参与 Q/P 除权;价格调整模式启用后再根据需求 考虑接入该配置。 + // var dividendTaxRate = GetFundDividendTaxRate(); + SwapEodPositionService.ApplyCorporateActionToPosition( position, corporateAction, closePrice, - dividendTaxRate); + 0m); } return true; @@ -445,7 +442,7 @@ namespace YLErp.Modules.SwapModule if (requestedQty < 0m || (!fullClose && requestedQty > effectiveQty)) { throw new ServiceException( - $"Fund 浮动腿平仓数量 {requestedQty} 超过截至 {valueDate:yyyy-MM-dd} 有效持仓 {effectiveQty}"); + $"Stock/Fund 浮动腿平仓数量 {requestedQty} 超过截至 {valueDate:yyyy-MM-dd} 有效持仓 {effectiveQty}"); } var closeQty = fullClose ? effectiveQty : requestedQty; @@ -550,10 +547,10 @@ namespace YLErp.Modules.SwapModule td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); var oriPosition = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).FirstOrDefault(); - // Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50, + // Stock/Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50, // 不能继续读取实时表中的 1000 份/100 再让前端重复套用除权系数。 - var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate); - // 恢复失败表示非 Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值, + var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(position, dealDate); + // 恢复失败表示非 Stock/Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值, // 继续原有盘中流程,避免用不完整快照制造数量/价格。 var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps); var hasProcess = HasTradeProcess(); @@ -590,8 +587,8 @@ namespace YLErp.Modules.SwapModule unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); // 现金分红会调整 EOD 期初价但不改数量,因此持仓名义本金可能从 100000 变为 99000。 - // 只有 Fund EOD 基线恢复成功时才使用该值;其他品种继续沿用 trade 原口径。 - unwindData.PosiNotionalValue = restoredFundBaseline + // 只有 Stock/Fund EOD 基线恢复成功时才使用该值;其他品种继续沿用 trade 原口径。 + unwindData.PosiNotionalValue = restoredCorporateActionBaseline ? position.PosiNotionalValue : Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); @@ -783,8 +780,8 @@ namespace YLErp.Modules.SwapModule var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); - // 收益结算与手工平仓共用 Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。 - var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate); + // 收益结算与手工平仓共用 Stock/Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。 + var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(position, dealDate); // 若无法恢复(例如当日已有互换/平仓流水),这里故意沿用实时腿,不能把较早 EOD // 当作当日最终状态;收益结算的其余字段仍按原始实时口径组装。 //var preSettleDate = CheckLastEod(dealDate, td.StartDate.Value, tradeId);//上一交易日期 @@ -823,7 +820,7 @@ namespace YLErp.Modules.SwapModule unwindData.StructureType = td.StructureType; unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); - unwindData.PosiNotionalValue = restoredFundBaseline + unwindData.PosiNotionalValue = restoredCorporateActionBaseline ? position.PosiNotionalValue : Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); @@ -1829,13 +1826,13 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } NormalizeEventUnwindDate(unwindData); - // 提交时再次从有效 EOD/实时腿复核基线,不能只相信前端缓存的数量和价格。 - var restoredFundBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate); - // 这是直接提交路径的最后一道复核。若返回 false(非 Fund、无快照、或 EOD 后已有完成流水), + // 提交时再次从有效 EOD/实时腿复核 Stock/Fund 基线,不能只相信前端缓存的数量和价格。 + var restoredCorporateActionBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate); + // 这是直接提交路径的最后一道复核。若返回 false(非 Stock/Fund、无快照、或 EOD 后已有完成流水), // 不改写前端数据,沿用当日实时持仓;审批冻结事件和自动平仓入口不经过此复核,见下方说明。 - if (restoredFundBaseline) + if (restoredCorporateActionBaseline) { - // 正式提交必须让交易级余额与同一 Fund EOD 基线一致,再执行原有扣减。 + // 正式提交必须让交易级余额与同一 Stock/Fund EOD 基线一致,再执行原有扣减。 // 例:派现后有效名义本金为 99000,平掉一半 49500 后应剩 49500; // 若仍从 trade 旧值 100000 扣减,会错误留下 50500。 td.StockEqvNotional = Convert.ToDouble(unwindData.PosiNotionalValue); @@ -1902,7 +1899,7 @@ namespace YLErp.Modules.SwapModule var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); - // 自动平仓由系统流水直接生成,当前入口沿用实时持仓和传入平仓数量,未重新读取 Fund EOD。 + // 自动平仓由系统流水直接生成,当前入口沿用实时持仓和传入平仓数量,未重新读取 Stock/Fund EOD。 // 因此它不具备手工 SwapUnwind 的 EOD 复核保护,生产上需确保自动流水已在正确的 EOD 基线之后生成。 var storagePriceRound = ConsGlobal.InstrumentType.IsBond(position?.UnderlyingInstrumentType) ? ConsGlobal.PriceRound @@ -2007,7 +2004,7 @@ namespace YLErp.Modules.SwapModule int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode); // 债券付息按每百元票息存储,继续走 BondPriceConverter;Stock/Fund 的公司行为 - // 现金分红按每 10 份金额存储,实际现金就是 payment_interest * qty,不能 /100。 + // 现金分红按每 10 份金额存储,实际现金 = payment_interest * qty / 10,不能 /100。 // 标的资料缺失时保持旧债券口径,避免未知标的的历史平仓金额被放大。 var useBondPriceScale = um == null || !SwapEodPositionService.IsCorporateActionInstrument(um.UnderlyingInstrumentType); @@ -2326,7 +2323,7 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } NormalizeEventUnwindDate(unwindData); - // 正常页面先由 InitIncome 读取最近有效 Fund EOD;本提交方法本身不再重读快照, + // 正常页面先由 InitIncome 读取最近有效 Stock/Fund EOD;本提交方法本身不再重读快照, // 直接使用调用方传入的数据。若数据来自待复核事件,则它是申请时冻结的快照,日期之后的除权 // 不会在这里回写,属于审批链路的残余风险。 ValidateIncomeValueDate(unwindData, td); @@ -2365,7 +2362,7 @@ namespace YLErp.Modules.SwapModule { throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } - // 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Fund EOD 重建数量和价格。 + // 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Stock/Fund EOD 重建数量和价格。 // 这是为了保持待复核事件可重放的一致性,但也意味着申请后发生除权时仍可能带入冻结的旧基线; // 直接提交路径的 EOD 复核不覆盖此审批路径。 swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); @@ -2462,7 +2459,7 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } NormalizeEventUnwindDate(unwindData); - // 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Fund EOD 复核。 + // 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Stock/Fund EOD 复核。 // 因而申请发生在除权前、审批发生在除权后的场景,冻结数据仍是旧基线,需重新发起申请才能刷新。 if (eventType == (int)SwapEventTypeEnum.互换) { diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index af6b23ad..85b2d474 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -78,7 +78,7 @@ namespace YLErp.Modules.SwapModule } /// - /// 从已经确认的 Fund EOD 快照恢复实时浮动腿的有效基线。 + /// 从已经确认的 Stock/Fund EOD 快照恢复实时浮动腿的有效基线。 /// 该方法只复制 EOD 已落库的数量、价格、名义本金及累计分红/待结费用,不再次计算 /// 公司行动系数,因此是幂等的。例:原 1000 份、期初价 100,10 送 10 后 EOD 为 /// 2000 份、50;下一日盘中直接恢复 2000/50,不能再变成 4000/25。 @@ -112,7 +112,7 @@ namespace YLErp.Modules.SwapModule } /// - /// 查询 valueDate 之前最近一份有效 Fund EOD 快照,作为盘中操作的日初基线。 + /// 查询 valueDate 之前最近一份有效 Stock/Fund EOD 快照,作为盘中操作的日初基线。 /// 必须严格使用 < valueDate:试算日当天的 EOD 可能尚未完成,或是重收盘留下的待重建数据, /// 不能反向覆盖盘中实时持仓。例:D 日 10 送 10 后 EOD 为 2000 份/50,D+1 盘中读取 D; /// D 日盘中只读取 D-1,不会误把 D 日半成品当成已生效基线。Invalid 明细始终排除。 @@ -446,25 +446,7 @@ namespace YLErp.Modules.SwapModule .ToList(); } - /// - /// 查找结算日有效的公司行为记录;测试可替换为内存数据。 - /// settleDate 必须是收盘作业使用的日期边界(通常为 00:00:00),这里沿用完整 - /// DateTime 相等匹配;历史数据若带时分秒或为空,不会被静默归入当天,需在作业前 - /// 通过数据预检查处理,而不是让收盘在错误基线上继续计算。 - /// - protected virtual List FindExDividendInfos(DateTime settleDate) - { - return DbContext.ex_dividend_info - .Where(x => x.ValidStatus - && x.EffectiveDate.HasValue - && x.EffectiveDate.Value == settleDate.Date) - .ToList(); - } - - /// - /// 查询登记日或真实生效日命中的公司行为。保留 FindExDividendInfos 这个 - /// 可替换入口,测试和历史调用方可以继续注入内存数据。 - /// + /// 查询登记日或真实生效日命中的有效公司行为。 protected virtual List FindCorporateActionInfos(DateTime settleDate) { return DbContext.ex_dividend_info @@ -490,19 +472,6 @@ namespace YLErp.Modules.SwapModule UpdateDbOption(swapEvent); } - /// - /// 查找登记日公司行为。登记日只创建待生效审计事件,不参与当日持仓系数计算; - /// EffectiveDate 到达后才由 FindExDividendInfos 命中并改变 Stock/Fund 基线。 - /// - protected virtual List FindRegistrationExDividendInfos(DateTime settleDate) - { - return FindCorporateActionInfos(settleDate) - .Where(x => x.ValidStatus - && x.ExDividendDate.HasValue - && x.ExDividendDate.Value.Date == settleDate.Date) - .ToList(); - } - /// /// 获取公司行为公式使用的收盘价。 /// EffectiveDate 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日 @@ -523,16 +492,6 @@ namespace YLErp.Modules.SwapModule return Convert.ToDecimal(closePrice); } - /// - /// 公司行为现金分红使用的系统税率,小数形式。 - /// 系统配置按百分数存储(例如 13 表示 13%),公司行为公式需要 0.13;送股本身 - /// 不受该税率影响;此处只负责读取并换算,不在异常时擅自默认为 0。 - /// - protected virtual decimal GetDividendTaxRate() - { - return new DividendService(this).GetDividendTaxRateDecimal(); - } - public static bool IsCorporateActionInstrument(string instrumentType) { // TRS 公司行为本期只覆盖 Stock/Fund。TBonds 等类型继续走原债券付息链路, @@ -700,7 +659,7 @@ namespace YLErp.Modules.SwapModule flowEvents); // 现金分红不在登记日直接读取 ex_dividend_info 累加。 - // 同步任务会把 GiveCashAmount/10 写入 bond_payment_info,Copy/Update EOD 在 + // 同步任务会把 GiveCashAmount(每 10 份派现金额)写入 bond_payment_info,Copy/Update EOD 在 // EffectiveDate 通过 CalcBondPayment 命中该行并生成 TdPosiDividend。 // 这样登记日快照不提前变化,也不会与债券付息/平仓链路重复计算。 RecordCorporateActionEvents( @@ -710,6 +669,9 @@ namespace YLErp.Modules.SwapModule registrationInfos, exDividendInfos, settleDate); + // 登记日 EOD 仍保存除权前快照,但下一交易日开盘读取的实时浮动腿需要 + // 先切换到生效后的 Q/P。该更新基于当日 EOD 恢复后再套系数,重收盘不会重复放大。 + UpdateRealtimeCorporateActionPositions(td, curEodPosis, registrationInfos, exDividendInfos, 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); @@ -741,7 +703,7 @@ namespace YLErp.Modules.SwapModule } /// - /// 把上一实际 EOD 复制成“当日开盘基线”,并在需要时套用当日生效的 Fund 公司行为。 + /// 把上一实际 EOD 复制成“当日开盘基线”,并在需要时套用当日生效的 Stock/Fund 公司行为。 /// 原始上一 EOD 只读保留在数据库中,确保登记日 EOD 报表仍展示除权前 Q/P。 /// 例如 1000 份/100 元、10 送 10 的记录在 8 月 14 日 EOD 仍是 1000/100; /// 8 月 17 日处理当日流水前,内存基线先转为 2000/50,再平仓 300 份得到 1700/50。 @@ -765,10 +727,8 @@ namespace YLErp.Modules.SwapModule .Select(x => x.Clone()) .ToList(); // 应用公司行为 - ApplyFundCorporateActions( + ApplyCorporateActions( openingPositions, - Array.Empty(), - Array.Empty(), exDividendByCode, settleDate); return openingPositions; @@ -791,10 +751,8 @@ namespace YLErp.Modules.SwapModule /// EffectiveDate 再生成开盘基线。 /// /// - protected void ApplyFundCorporateActions( + protected void ApplyCorporateActions( IEnumerable positions, - IReadOnlyCollection previousEodPositions, - IReadOnlyCollection flowEvents, IReadOnlyDictionary exDividendByCode, DateTime settleDate) { @@ -803,8 +761,9 @@ namespace YLErp.Modules.SwapModule return; } - // 获取系统税率 - var dividendTaxRate = GetDividendTaxRate(); + // TODO: 现金分红税率接入后,仅价格调整模式需要读取税率;TRS 现金模式下不参与除权系数。 + // var dividendTaxRate = GetDividendTaxRate(); + var dividendTaxRate = 0m; foreach (var position in positions) { if (position.PosiDirection <= 0 @@ -824,41 +783,7 @@ namespace YLErp.Modules.SwapModule if (corporateActionClosePrice <= 0) { throw new InvalidOperationException( - $"Fund 标的【{position.UnderlyingCode}】登记日【{dividendInfo.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); - } - - // 当天已有 EOD 且该腿没有流水时,Copy 分支不会恢复价格字段。 - // 先还原前一日基线,避免同一结算日重跑时再次除权。 - var hasPositionFlow = flowEvents.Any(x => x.PositionId == position.PositionId); - var previousPosition = previousEodPositions.FirstOrDefault( - x => x.PositionId == position.PositionId); - // 没有流水 且 有前一日持仓时,恢复前一日价格字段 - if (!hasPositionFlow && previousPosition != null) - { - position.PosiQuantity = previousPosition.PosiQuantity; - position.PosiGrossPrice = previousPosition.PosiGrossPrice; - position.PosiNetPrice = previousPosition.PosiNetPrice; - position.PosiNetFeePrice = previousPosition.PosiNetFeePrice; - position.PosiNetNoFeePrice = previousPosition.PosiNetNoFeePrice; - } - - var denominator = 10m + dividendInfo.GiveShareAmount + dividendInfo.RationedSharesAmount; - if (denominator == 0) - { - throw new InvalidOperationException( - $"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】的除权份额参数导致除数为 0"); - } - - // 计算除权系数 - var factors = DividendService.CalculateCorporateActionFactors( - dividendInfo, - corporateActionClosePrice, - dividendTaxRate, - adjustCashDividendPrice: false); - if (factors.PriceRatio <= 0) - { - throw new InvalidOperationException( - $"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】计算得到无效除权系数"); + $"Stock/Fund 标的【{position.UnderlyingCode}】登记日【{dividendInfo.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); } // Excel 公式 口径:PriceRatio 是“登记日收盘价 / 除权参考价”, @@ -866,37 +791,24 @@ namespace YLErp.Modules.SwapModule // 配股已经进入 价格参考价,所以即使没有送股,配股也会调整 TRS 数量; // 现金分红不影响 TRS Stock/Fund 期初价格,现金权益由独立分红字段处理。 var originalQuantity = position.PosiQuantity; - position.PosiQuantity = Math.Round( - originalQuantity * factors.PriceRatio, - 12, - MidpointRounding.AwayFromZero); + // 计算公司行为发生后的 Q/P + var adjusted = CalculateCorporateActionValues( + position.PosiQuantity, + position.PosiGrossPrice, + position.PosiNetPrice, + position.PosiNetFeePrice, + position.PosiNetNoFeePrice, + dividendInfo, + corporateActionClosePrice, + dividendTaxRate, + GetStorageDeliveryPriceRound(position.UnderlyingInstrumentType, position.UnderlyingCode)); + position.PosiQuantity = adjusted.Quantity; position.TdChangedQty = position.PosiQuantity - originalQuantity; - var storagePriceRound = GetStorageDeliveryPriceRound( - position.UnderlyingInstrumentType, - position.UnderlyingCode); - position.PosiGrossPrice = Math.Round( - position.PosiGrossPrice / factors.PriceRatio, - storagePriceRound, - MidpointRounding.AwayFromZero); - position.PosiNetPrice = Math.Round( - position.PosiNetPrice / factors.PriceRatio, - ConsGlobal.PriceRound, - MidpointRounding.AwayFromZero); - if (position.PosiNetFeePrice.HasValue) - { - position.PosiNetFeePrice = Math.Round( - position.PosiNetFeePrice.Value / factors.PriceRatio, - ConsGlobal.PriceRound, - MidpointRounding.AwayFromZero); - } - if (position.PosiNetNoFeePrice.HasValue) - { - position.PosiNetNoFeePrice = Math.Round( - position.PosiNetNoFeePrice.Value / factors.PriceRatio, - ConsGlobal.PriceRound, - MidpointRounding.AwayFromZero); - } + position.PosiGrossPrice = adjusted.GrossPrice; + position.PosiNetPrice = adjusted.NetPrice; + position.PosiNetFeePrice = adjusted.NetFeePrice; + position.PosiNetNoFeePrice = adjusted.NetNoFeePrice; var shortRatio = DirectionRatio.LongShort(position.PositionType); var directionRatio = DirectionRatio.ReceivePay(position.PosiDirection); @@ -998,16 +910,79 @@ namespace YLErp.Modules.SwapModule } var closePrice = GetFundCorporateActionClosePrice(info, position.PosiGrossPrice); - ApplyFundCorporateActionToPosition( + ApplyCorporateActionToPosition( position, info, closePrice, - GetDividendTaxRate()); + 0m); } return positions; } + /// + /// 同步公司行为后的实时浮动腿。 + /// 登记日只更新下一交易日 BOD 使用的实时 Q/P,不改当日已落库的 EOD; + /// 生效日则把已调整的 EOD 复制到实时腿。每次都先从当日 EOD 恢复,保证重跑幂等。 + /// + private void UpdateRealtimeCorporateActionPositions( + trade td, + IReadOnlyCollection currentEodPositions, + IReadOnlyCollection registrationInfos, + IReadOnlyCollection effectiveInfos, + DateTime settleDate) + { + if (td == null || currentEodPositions == null || currentEodPositions.Count == 0) + { + return; + } + + // 登记日收盘后即切换实时 BOD。EffectiveDate 只用于确认这条记录仍是未来生效的 + // 公司行为;无论登记日与生效日之间有一个还是多个非交易日,都不能漏掉这次切换。 + var pendingInfos = (registrationInfos ?? Array.Empty()) + .Where(x => x.EffectiveDate.HasValue && x.EffectiveDate.Value.Date > settleDate.Date) + .ToList(); + var appliedInfos = effectiveInfos ?? Array.Empty(); + + foreach (var eod in currentEodPositions.Where(x => x != null + && x.PosiDirection > 0 + && IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType) + && !string.IsNullOrWhiteSpace(x.UnderlyingCode))) + { + var realtime = DbContext.swap_position.FirstOrDefault(x => x.SwapTradeId == td.id + && !x.Invalid + && !x.IsInitial + && x.PositionId == eod.PositionId); + if (realtime == null) + { + continue; + } + + var pending = pendingInfos.FirstOrDefault(x => string.Equals( + x.UnderlyingCode, eod.UnderlyingCode, StringComparison.OrdinalIgnoreCase)); + if (pending != null) + { + // 必须从登记日 EOD 基线生成下一交易日 BOD,而不是在旧实时腿上继续套系数; + // 这样 100000/100 只会变成一次 200000/50,并且不会把初始腿改掉。 + // 先将实时腿恢复为登记日 EOD 的旧基线,再只对实时腿应用一次公司行为。 + // EOD 仍保持除权前快照;因此 7/10 EOD=100000/100,而 7/13 BOD=200000/50。 + var baseline = eod.Clone(); + UpdateSwapPositionWithRealTime(baseline); + var closePrice = GetFundCorporateActionClosePrice(pending, baseline.PosiGrossPrice); + ApplyCorporateActionToPosition(realtime, pending, closePrice, 0m); + continue; + } + + var applied = appliedInfos.FirstOrDefault(x => string.Equals( + x.UnderlyingCode, eod.UnderlyingCode, StringComparison.OrdinalIgnoreCase)); + if (applied != null) + { + // 生效日 EOD 已经完成 Q/P 调整,实时腿直接同步最终快照,不再二次套系数。 + UpdateSwapPositionWithRealTime(eod.Clone()); + } + } + } + /// /// 写入公司行为生命周期审计事件。 /// 登记日:保存调整前快照并标记 Applied=false; @@ -1212,72 +1187,66 @@ namespace YLErp.Modules.SwapModule return SwapEventService.BuildCorporateActionEventReason(data); } - /// - /// 兼容旧测试/扩展调用的直接现金分红辅助方法。 - /// GiveCashAmount 按每 10 份金额计算:1000 份、每 10 份派 10,结果为 1000。 - /// 当前生产 SwapPositionCompose 不再调用此方法:公司行为现金分红由同步任务 - /// 写入 bond_payment_info,EffectiveDate 收盘通过 CalcBondPayment 进入 EOD, - /// 以避免登记日提前入账及与债券付息链路重复。保留方法是为了不破坏已有测试替身 - /// 或外部扩展类的编译契约;新增业务代码不得再直接传入 ex_dividend_info。 - /// - protected void ApplyFundCashDividends( - IReadOnlyCollection currentEodPositions, - IReadOnlyCollection previousEodPositions, - IReadOnlyDictionary exDividendByCode, - DateTime settleDate) + /// 公司行为调整后的持仓 Q/P 结果,供 EOD、实时腿和盘中平仓共用。 + private readonly struct CorporateActionValues { - if (currentEodPositions == null - || exDividendByCode == null - || exDividendByCode.Count == 0) + public CorporateActionValues(decimal quantity, decimal grossPrice, decimal netPrice, decimal? netFeePrice, decimal? netNoFeePrice) { - return; + Quantity = quantity; + GrossPrice = grossPrice; + NetPrice = netPrice; + NetFeePrice = netFeePrice; + NetNoFeePrice = netNoFeePrice; } - var dividendTaxRate = GetDividendTaxRate(); - var previousList = previousEodPositions ?? Array.Empty(); - foreach (var current in currentEodPositions) + public decimal Quantity { get; } + public decimal GrossPrice { get; } + public decimal NetPrice { get; } + public decimal? NetFeePrice { get; } + public decimal? NetNoFeePrice { get; } + } + + /// + /// 统一计算公司行为后的 Q/P。EOD、实时腿和盘中平仓只负责提供基线, + /// 不再各自复制数量、毛价和净价的调整公式。 + /// + private static CorporateActionValues CalculateCorporateActionValues( + decimal quantity, + decimal grossPrice, + decimal netPrice, + decimal? netFeePrice, + decimal? netNoFeePrice, + ex_dividend_info dividendInfo, + decimal closePrice, + decimal dividendTaxRate, + int grossPriceRound) + { + var factors = DividendService.CalculateCorporateActionFactors( + dividendInfo, + closePrice, + dividendTaxRate, + adjustCashDividendPrice: false); + if (factors.PriceRatio <= 0) { - if (current == null - || current.PosiDirection == 0 - || !IsTrsCorporateActionInstrument(current.UnderlyingInstrumentType) - || string.IsNullOrWhiteSpace(current.UnderlyingCode) - || !exDividendByCode.TryGetValue(current.UnderlyingCode, out var dividendInfo) - || !dividendInfo.ExDividendDate.HasValue - || dividendInfo.ExDividendDate.Value.Date != settleDate.Date) - { - continue; - } - - var previous = previousList.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; - - // 当日浮动端分红。公司行为现金分红采用现金模式:不调期初价格, - // 只增加待实现分红,支付日仍由既有 DealDividends/付息链路结算。 - current.TdPosiDividend = RoundMoney(currentDividend); - var previousDividendSum = previous?.PosiDividendSum ?? 0m; - // 浮动端平仓盈亏·分红未实现 = 前日待实现 + 当日公司行为分红 - // - 当日已实现分红;本次公司行为尚未支付,因此不能写入 RealizedDividend。 - current.PosiDividendSum = current.PosiQuantity > 0m - ? RoundMoney(previousDividendSum + current.TdPosiDividend - current.TdCloseDividend) - : 0m; - // 现金模式不从 PosiMtmPnL 剥离分红:价格没有被除权,分红只存在于待实现字段。 - current.PosiProfitSum = RoundMoney(MtmCalc.ReturnLegProfitSum( - current.PosiMtmPnL, - current.PosiDividendSum, - current.PosiFeePending)); - SetFloatingRealizedPnl(current); - current.SwapPositionValue = RoundMoney(PositionValueCalc.Calc( - current.InterestProfitSum, - current.PosiProfitSum)); + throw new InvalidOperationException( + $"标的【{dividendInfo?.UnderlyingCode}】计算得到无效除权系数"); } + + var adjustedQuantity = Math.Round(quantity * factors.PriceRatio, 12, MidpointRounding.AwayFromZero); + var adjustedGrossPrice = Math.Round(grossPrice / factors.PriceRatio, grossPriceRound, MidpointRounding.AwayFromZero); + var adjustedNetPrice = Math.Round(netPrice / factors.PriceRatio, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero); + var adjustedNetFeePrice = netFeePrice.HasValue + ? Math.Round(netFeePrice.Value / factors.PriceRatio, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero) + : (decimal?)null; + var adjustedNetNoFeePrice = netNoFeePrice.HasValue + ? Math.Round(netNoFeePrice.Value / factors.PriceRatio, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero) + : (decimal?)null; + return new CorporateActionValues( + adjustedQuantity, + adjustedGrossPrice, + adjustedNetPrice, + adjustedNetFeePrice, + adjustedNetNoFeePrice); } /// @@ -1288,7 +1257,7 @@ namespace YLErp.Modules.SwapModule /// 现金模式调用公式时使用 adjustCashDividendPrice=false,现金权益只进入分红字段, /// 不改变 Stock/Fund 的期初价格。 /// - public static bool ApplyFundCorporateActionToPosition( + public static bool ApplyCorporateActionToPosition( swap_position position, ex_dividend_info dividendInfo, decimal corporateActionClosePrice, @@ -1303,44 +1272,21 @@ namespace YLErp.Modules.SwapModule return false; } - var factors = DividendService.CalculateCorporateActionFactors( + var adjusted = CalculateCorporateActionValues( + position.PosiQuantity, + position.PosiGrossPrice, + position.PosiNetPrice, + position.PosiNetFeePrice, + position.PosiNetNoFeePrice, dividendInfo, corporateActionClosePrice, dividendTaxRate, - adjustCashDividendPrice: false); - if (factors.PriceRatio <= 0) - { - throw new InvalidOperationException( - $"Fund 标的【{position.UnderlyingCode}】计算得到无效除权系数"); - } - - var originalQuantity = position.PosiQuantity; - position.PosiQuantity = Math.Round( - originalQuantity * factors.PriceRatio, - 12, - MidpointRounding.AwayFromZero); - position.PosiGrossPrice = Math.Round( - position.PosiGrossPrice / factors.PriceRatio, - ConsGlobal.SwapDeliveryPriceRound, - MidpointRounding.AwayFromZero); - position.PosiNetPrice = Math.Round( - position.PosiNetPrice / factors.PriceRatio, - ConsGlobal.PriceRound, - MidpointRounding.AwayFromZero); - if (position.PosiNetFeePrice.HasValue) - { - position.PosiNetFeePrice = Math.Round( - position.PosiNetFeePrice.Value / factors.PriceRatio, - ConsGlobal.PriceRound, - MidpointRounding.AwayFromZero); - } - if (position.PosiNetNoFeePrice.HasValue) - { - position.PosiNetNoFeePrice = Math.Round( - position.PosiNetNoFeePrice.Value / factors.PriceRatio, - ConsGlobal.PriceRound, - MidpointRounding.AwayFromZero); - } + ConsGlobal.SwapDeliveryPriceRound); + position.PosiQuantity = adjusted.Quantity; + position.PosiGrossPrice = adjusted.GrossPrice; + position.PosiNetPrice = adjusted.NetPrice; + position.PosiNetFeePrice = adjusted.NetFeePrice; + position.PosiNetNoFeePrice = adjusted.NetNoFeePrice; position.PosiNotionalValue = Math.Round( position.PosiGrossPrice * position.PosiQuantity * position.ContractSize, ConsGlobal.MoneyRound, diff --git a/YLErpDAL/Modules/SwapModule/SwapEventService.cs b/YLErpDAL/Modules/SwapModule/SwapEventService.cs index b9be5178..711bc06f 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEventService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEventService.cs @@ -207,8 +207,8 @@ namespace YLErp.Modules.SwapModule return events; } /// - /// 获取交易操作历史。返回前会过滤掉登记日创建且尚未应用(Applied=false) - /// 的公司行为事件,避免交易详情在真实调整前展示一条已完成历史。 + /// 获取交易操作历史。登记日创建但尚未到 EffectiveDate 的公司行为事件也保留, + /// 由 EventData.Applied=false 表示“待生效”,保证审计日志完整可追溯。 /// /// 交易id /// @@ -218,40 +218,7 @@ namespace YLErp.Modules.SwapModule .Where(x => x.SwapTradeId == tradeId) .OrderByDescending(o => o.id) .ToList(); - return FilterOperationHistory(list); - } - - /// - /// 过滤登记日创建且 Applied=false 的待生效公司行为事件,避免交易详情在真正 - /// 调整前展示一条“已完成”历史。其他事件仍保留;无法解析的旧格式公司行为也 - /// 保持可见,审计查询不能因为新 JSON 结构而静默丢失历史记录。 - /// - public static List FilterOperationHistory(IEnumerable events) - { - return (events ?? Enumerable.Empty()) - .Where(x => !IsPendingCorporateActionEvent(x)) - .ToList(); - } - - /// - /// 判断指定事件是否为待生效的公司行为事件:EventType 为公司行为(13), - /// 且 EventData 反序列化后 Applied=false(登记日写入、尚未在真实除权日补齐调整后数据)。 - /// 非公司行为类型、无法解析的旧格式或已应用的事件均返回 false,保证历史审计记录不被误删。 - /// - public static bool IsPendingCorporateActionEvent(swap_event swapEvent) - { - if (swapEvent == null || swapEvent.EventType != (int)SwapEventTypeEnum.公司行为) - { - return false; - } - - if (!TryDeserializeCorporateActionEventData(swapEvent, out var data)) - { - // 非快照格式的历史公司行为保持可见,避免误删审计记录。 - return false; - } - - return !data.Applied; + return list; } /// diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs index c2d9f3a9..e0068952 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs @@ -76,7 +76,12 @@ namespace YLErp.Modules.SwapModule /// public void UpdateSwapPositionWithRealTime(eod_swap_position eodPayPosition) { - var position = DbContext.swap_position.FirstOrDefault(x => x.PositionId == eodPayPosition.PositionId); + // 同一 PositionId 同时对应初始腿(id=PositionId)和实时腿(IsInitial=0)。 + // 日终/公司行为同步只能更新实时腿;若直接 FirstOrDefault,会随机命中初始腿, + // 造成 EOD 已是 200000/50 而交易详情仍保持 100000/100,或反向污染交易初始腿。 + var position = DbContext.swap_position.FirstOrDefault(x => x.PositionId == eodPayPosition.PositionId + && !x.IsInitial + && !x.Invalid); if (position != null) { position.PosiQuantity = eodPayPosition.PosiQuantity; @@ -96,7 +101,13 @@ namespace YLErp.Modules.SwapModule } else { + // 兼容实时腿尚未生成的首日/自动合成场景:只从初始腿克隆创建实时腿, + // 不能把初始腿当作可更新对象。 position = DbContext.swap_position.FirstOrDefault(x => x.id == eodPayPosition.PositionId); + if (position == null) + { + return; + } var posi = position.Clone(); posi.id = 0; posi.PositionId = position.id; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs index 1390a183..8b026f5d 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs @@ -1252,6 +1252,12 @@ namespace YLErp.Modules.TradeModule.DealModule { return true; } + + // EffectiveDate 已存在时,当前有效 EOD 是唯一执行状态来源。 + // 回退会清理生效日及之后的 EOD,但不会删除 eodStatus 或不可篡改的 + // 公司行为审计事件;此处不能继续落入旧的登记日 eodStatus 判断, + // 否则交易已回退仍会被错误判定为“已执行”而无法修改。 + return false; } }