From aa3548e77f45fa2ea2fe08ad242a7a7bd108746b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Wed, 19 Aug 2026 17:48:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(bond):=20=E6=94=AF=E6=8C=81=E5=80=BA?= =?UTF-8?q?=E5=88=B8=E4=B8=8E=E8=82=A1=E7=A5=A8=E5=9F=BA=E9=87=91=E5=85=AC?= =?UTF-8?q?=E5=8F=B8=E8=A1=8C=E4=B8=BA=E7=8E=B0=E9=87=91=E6=B5=81=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E7=9A=84=E5=B7=AE=E5=BC=82=E5=8C=96=E5=A4=84=E7=90=86?= =?UTF-8?q?=20-=20init2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 CalcPayment 方法添加 useBondPriceScale 参数区分债券和股票/基金的金额计算口径 - 债券利息按每100元面值票息通过BondPriceConverter转为入库金额,股票基金分红直接计算 - 在BondPaymentService中添加详细的参数说明文档注释 - 更新SwapDealService中分红计算逻辑,根据标的类型自动选择合适的金额转换方式 - 新增CorporateActionEventLifecycleTest单元测试验证公司行为事件生命周期管理 - 添加SplitCorporateActionTddTest测试验证拆合股功能 - 优化FundCorporateActionRollbackAndUnwindTest扩展到股票类型测试 - 更新前端OperationHistory页面表格列宽和显示格式支持更长的说明信息 --- .../DBModels/Enums/SwapEventTypeEnum.cs | 3 +- Framework/YLErp.Core/DBModels/SwapEvent.cs | 31 ++ .../SwapModule/BondTrsAutoSwapScenarioTest.cs | 15 +- .../CorporateActionEventLifecycleTest.cs | 303 +++++++++++ ...undCorporateActionRollbackAndUnwindTest.cs | 10 +- .../SwapModule/SplitCorporateActionTddTest.cs | 197 +++++++ .../SwapPositionComposeScenarioTest.cs | 14 +- .../TestableSwapEodPositionService.cs | 29 + YLErpDAL/Model/ExDividendInfo.cs | 7 + .../Modules/EodModule/BondPaymentService.cs | 37 +- .../Modules/SwapModule/SwapDealService.cs | 16 +- .../SwapModule/SwapEodPositionService.cs | 500 ++++++++++++++++-- .../Modules/SwapModule/SwapEventService.cs | 113 +++- .../Modules/SwapModule/SwapTradeService.cs | 25 +- .../TradeModule/DealModule/DividendService.cs | 132 ++++- .../App_Docs/导入模板/个股除权信息模板.xlsx | Bin 11261 -> 7018 bytes .../Controllers/ex_dividend_infoController.cs | 4 + .../Views/SwapTrade2/OperationHistory.cshtml | 6 +- .../app/underlying/underlyingDividendInfo.js | 4 + 19 files changed, 1342 insertions(+), 104 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs diff --git a/Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs b/Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs index 95d6fc27..5f607554 100644 --- a/Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs +++ b/Framework/YLErp.Core/DBModels/Enums/SwapEventTypeEnum.cs @@ -20,6 +20,7 @@ namespace YLErp.DBModels 确认交易=9, 审批通过=10, 审批拒绝=11, - 删除=12 + 删除=12, + 公司行为=13 } } diff --git a/Framework/YLErp.Core/DBModels/SwapEvent.cs b/Framework/YLErp.Core/DBModels/SwapEvent.cs index eff65bac..9501c4e4 100644 --- a/Framework/YLErp.Core/DBModels/SwapEvent.cs +++ b/Framework/YLErp.Core/DBModels/SwapEvent.cs @@ -82,6 +82,37 @@ namespace YLErp.DBModels [NotMapped] public UnwindData unwindData { get; set; } } + + /// + /// 公司行为事件快照。登记日先写入待生效快照,真实除权日补齐调整后数据; + /// 已应用快照只允许追加回退事件,不覆盖原记录。 + /// ExDividendDate 是登记日,EffectiveDate 是 Q/P 真实切换日;GiveShareAmount + /// 表示每 10 份增减数量,Split 表示独立拆/合股倍数(null 按 1)。Before/After + /// 分别保存调整前后名义本金、价格、数量和待实现分红,CashFlowChange 保存现金变化。 + /// + public class CorporateActionEventData + { + public int ExDividendInfoId { get; set; } + public long PositionId { get; set; } + public string UnderlyingCode { get; set; } + public DateTime? ExDividendDate { get; set; } + public DateTime? EffectiveDate { get; set; } + public decimal GiveCashAmount { get; set; } + public decimal GiveShareAmount { get; set; } + public decimal? Split { get; set; } + public decimal RationedSharesAmount { get; set; } + public decimal RationedSharesPrice { get; set; } + public decimal BeforeNotional { get; set; } + public decimal BeforePrice { get; set; } + public decimal BeforeQuantity { get; set; } + public decimal AfterNotional { get; set; } + public decimal AfterPrice { get; set; } + public decimal AfterQuantity { get; set; } + public decimal BeforePendingDividend { get; set; } + public decimal AfterPendingDividend { get; set; } + public decimal CashFlowChange { get; set; } + public bool Applied { get; set; } + } /// /// 展期信息 /// diff --git a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs index e9b542c8..baf4b420 100644 --- a/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/BondTrsAutoSwapScenarioTest.cs @@ -69,7 +69,10 @@ namespace YLErp.Modules.SwapModule public List<(DateTime valueDate, int eventType, string reason, UnwindData data)> SwapEvents { get; } = new(); /// 捕获落库的互换流水明细 - public List PersistedFlowEvents { get; } = new(); + public List PersistedFlowEvents => DbContext.swap_flow_event.Local.ToList(); + + /// 捕获资金流水的金额、操作类型和发生日 + public List<(double amount, string action, DateTime valueDate)> ClientCashCallDetails { get; } = new(); public AutoSwapEodService( List trades, List positions, @@ -112,6 +115,13 @@ namespace YLErp.Modules.SwapModule protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List eventTypes) { } public override void ClearSwapPositions(trade td, DateTime valueDate, List eventTypes, bool delAfter) { } + public override int AddClientCashInCashOut(OtcTradeBase td, double amount, string action, DateTime valueDate) + { + ClientCashCalls.Add((amount, action)); + ClientCashCallDetails.Add((amount, action, valueDate)); + return ClientCashCalls.Count; + } + protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason) { @@ -509,6 +519,9 @@ namespace YLErp.Modules.SwapModule $"分红支付日({actualPayDate:yyyy-MM-dd})不应早于结算日({PayDate:yyyy-MM-dd})"); Assert.IsFalse(QdpModule.QdpCalendarHelper.IsHoliday(actualPayDate), $"分红支付日({actualPayDate:yyyy-MM-dd})必须落在非假日"); + Assert.AreEqual(1, svc.ClientCashCallDetails.Count, "应生成 1 条分红资金流水"); + Assert.AreEqual(actualPayDate, svc.ClientCashCallDetails[0].valueDate, + "资金发生日应使用分红支付日"); } // ================================================================ diff --git a/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs new file mode 100644 index 00000000..7e755187 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/CorporateActionEventLifecycleTest.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.DBModels; +using YLErp.DBModels.Consts; + +namespace YLErp.Modules.SwapModule +{ + [TestClass] + public class CorporateActionEventLifecycleTest + { + // 8/14 登记日只创建 Applied=false 的待生效事件;8/17 真实生效日补齐 + // 同一事件的调整前后快照并标记 Applied=true。 + private static readonly DateTime RecordDate = new DateTime(2026, 8, 14); + private static readonly DateTime EffectiveDate = new DateTime(2026, 8, 17); + + [TestMethod] + public void RegistrationSnapshot_IsPending_AndKeepsBeforeFields() + { + var info = CreateAction(77, ConsGlobal.InstrumentType.Stock); + var before = CreateEodPosition(9, info.UnderlyingCode, 1000m, 100m); + + var snapshot = SwapEodPositionService.BuildCorporateActionEventData( + info, + before, + null, + applied: false); + + Assert.AreEqual(77, snapshot.ExDividendInfoId); + Assert.AreEqual(9L, snapshot.PositionId); + Assert.AreEqual(1000m, snapshot.BeforeQuantity); + Assert.AreEqual(100m, snapshot.BeforePrice); + Assert.AreEqual(100000m, snapshot.BeforeNotional); + Assert.AreEqual(0m, snapshot.AfterQuantity); + Assert.IsFalse(snapshot.Applied); + + var reason = SwapEventService.BuildCorporateActionEventReason(snapshot); + StringAssert.Contains(reason, "BeforeQuantity=1000"); + StringAssert.Contains(reason, "AfterQuantity=0"); + } + + [TestMethod] + public void EffectiveSnapshot_ContainsAfterFields_AndSupportsStockAndFund() + { + var info = CreateAction(78, ConsGlobal.InstrumentType.Fund); + var before = CreateEodPosition(10, info.UnderlyingCode, 1000m, 100m); + var after = CreateEodPosition(10, info.UnderlyingCode, 2000m, 50m); + + var snapshot = SwapEodPositionService.BuildCorporateActionEventData( + info, + before, + after, + applied: true); + + Assert.AreEqual(1000m, snapshot.BeforeQuantity); + Assert.AreEqual(100m, snapshot.BeforePrice); + Assert.AreEqual(2000m, snapshot.AfterQuantity); + Assert.AreEqual(50m, snapshot.AfterPrice); + Assert.AreEqual(100000m, snapshot.AfterNotional); + Assert.IsTrue(snapshot.Applied); + Assert.IsTrue(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.Stock)); + Assert.IsTrue(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.Fund)); + Assert.IsFalse(SwapEodPositionService.IsCorporateActionInstrument(ConsGlobal.InstrumentType.TBonds)); + } + + [TestMethod] + public void Rerun_DoesNotCreateDuplicateCorporateActionEvent() + { + var info = CreateAction(79, ConsGlobal.InstrumentType.Stock); + var snapshot = SwapEodPositionService.BuildCorporateActionEventData( + info, + CreateEodPosition(11, info.UnderlyingCode, 1000m, 100m), + null, + applied: false); + var existing = new swap_event + { + SwapTradeId = 100, + EventType = (int)SwapEventTypeEnum.公司行为, + EventData = JsonConvert.SerializeObject(snapshot), + Invalid = false + }; + + Assert.IsFalse(SwapEodPositionService.ShouldCreateCorporateActionEvent( + new[] { existing }, + info, + 11L)); + } + + [TestMethod] + public void LegacyEventWithoutExDividendInfoId_DoesNotBlockCurrentEvent() + { + var info = CreateAction(79, ConsGlobal.InstrumentType.Stock); + var legacySnapshot = SwapEodPositionService.BuildCorporateActionEventData( + info, + CreateEodPosition(11, info.UnderlyingCode, 1000m, 100m), + null, + applied: false); + legacySnapshot.ExDividendInfoId = 0; + var legacyEvent = new swap_event + { + SwapTradeId = 100, + EventType = (int)SwapEventTypeEnum.公司行为, + EventData = JsonConvert.SerializeObject(legacySnapshot), + Invalid = false + }; + + Assert.IsTrue(SwapEodPositionService.ShouldCreateCorporateActionEvent( + new[] { legacyEvent }, + info, + 11L)); + } + + [TestMethod] + public void OperationHistory_FiltersPendingCorporateActionOnly() + { + var info = CreateAction(80, ConsGlobal.InstrumentType.Stock); + var pendingData = SwapEodPositionService.BuildCorporateActionEventData( + info, + CreateEodPosition(12, info.UnderlyingCode, 1000m, 100m), + null, + applied: false); + var appliedData = SwapEodPositionService.BuildCorporateActionEventData( + info, + CreateEodPosition(13, info.UnderlyingCode, 1000m, 100m), + CreateEodPosition(13, info.UnderlyingCode, 2000m, 50m), + applied: true); + var events = new List + { + new swap_event { id = 1, EventType = (int)SwapEventTypeEnum.公司行为, EventData = JsonConvert.SerializeObject(pendingData) }, + new swap_event { id = 2, EventType = (int)SwapEventTypeEnum.公司行为, EventData = JsonConvert.SerializeObject(appliedData) }, + 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); + } + + [TestMethod] + public void EffectiveCorporateAction_AdjustsStockQuantityAndPrice() + { + var position = new swap_position + { + PositionId = 14, + PosiDirection = 1, + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock, + UnderlyingCode = "STOCK.TEST", + PosiQuantity = 1000m, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + ContractSize = 1m + }; + var info = CreateAction(81, ConsGlobal.InstrumentType.Stock); + info.GiveShareAmount = 10m; + + var applied = SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, + info, + 100m, + 0m); + + Assert.IsTrue(applied); + Assert.AreEqual(2000m, position.PosiQuantity); + Assert.AreEqual(50m, position.PosiGrossPrice); + Assert.AreEqual(100000m, position.PosiNotionalValue); + } + + [TestMethod] + public void Lifecycle_RegistrationIsIdempotent_ThenEffectiveUpdatesSameEvent() + { + var info = CreateAction(82, ConsGlobal.InstrumentType.Stock); + var before = CreateEodPosition(15, info.UnderlyingCode, 1000m, 100m); + var after = CreateEodPosition(15, info.UnderlyingCode, 2000m, 50m); + var service = new EventRecordingService(); + var trade = new trade { id = 100 }; + + service.Record( + trade, + new[] { before }, + Array.Empty(), + new[] { info }, + Array.Empty(), + RecordDate); + service.Record( + trade, + new[] { before }, + Array.Empty(), + new[] { info }, + Array.Empty(), + RecordDate); + + Assert.AreEqual(1, service.Events.Count); + Assert.AreEqual(1000m, before.PosiQuantity, "登记日不能改持仓数量"); + Assert.AreEqual(100m, before.PosiGrossPrice, "登记日不能改持仓价格"); + var pending = JsonConvert.DeserializeObject(service.Events[0].EventData); + Assert.IsFalse(pending.Applied); + Assert.AreEqual(RecordDate, service.Events[0].ValueDate.Date); + + service.Record( + trade, + new[] { after }, + new[] { before }, + Array.Empty(), + new[] { info }, + EffectiveDate); + + Assert.AreEqual(1, service.Events.Count, "生效日应更新原事件而非新增事件"); + Assert.AreEqual(1, service.UpdateCount); + var applied = JsonConvert.DeserializeObject(service.Events[0].EventData); + Assert.IsTrue(applied.Applied); + Assert.AreEqual(1000m, applied.BeforeQuantity); + Assert.AreEqual(2000m, applied.AfterQuantity); + Assert.AreEqual(50m, applied.AfterPrice); + Assert.AreEqual(RecordDate, service.Events[0].ValueDate.Date); + } + + private sealed class EventRecordingService : TestableSwapEodPositionService + { + public List Events { get; } = new List(); + public int UpdateCount { get; private set; } + + public EventRecordingService() + : base(nameof(CorporateActionEventLifecycleTest)) + { + } + + protected override List FindCorporateActionEvents(int swapTradeId) + { + return Events; + } + + protected override swap_event AddSwapEvent( + DateTime tradeDate, + int swapTradeId, + int eventType, + string data, + int clientCashId, + bool save, + string reason) + { + return new swap_event { id = Events.Count + 1 }; + } + + protected override void UpdateCorporateActionEventRecord(swap_event swapEvent) + { + UpdateCount++; + } + + public void Record( + trade trade, + IReadOnlyCollection current, + IReadOnlyCollection previous, + IReadOnlyCollection registration, + IReadOnlyCollection effective, + DateTime settleDate) + { + RecordCorporateActionEvents( + trade, + current, + previous, + registration, + effective, + settleDate); + } + } + + private static ex_dividend_info CreateAction(int id, string instrumentType) + { + return new ex_dividend_info + { + id = id, + UnderlyingCode = instrumentType == ConsGlobal.InstrumentType.Fund ? "FUND.TEST" : "STOCK.TEST", + ExDividendDate = RecordDate, + EffectiveDate = EffectiveDate, + GiveShareAmount = 0m, + GiveCashAmount = 0m, + ValidStatus = true + }; + } + + private static eod_swap_position CreateEodPosition(long positionId, string code, decimal quantity, decimal price) + { + return new eod_swap_position + { + PositionId = positionId, + UnderlyingCode = code, + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock, + PosiQuantity = quantity, + PosiGrossPrice = price, + PosiNotionalValue = quantity * price, + PosiNetPrice = price, + ContractSize = 1m, + PosiDirection = 1, + PositionType = 1 + }; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs index ed4f446b..8531e661 100644 --- a/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs +++ b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs @@ -6,6 +6,8 @@ namespace YLErp.Modules.SwapModule [TestClass] public class FundCorporateActionRollbackAndUnwindTest { + // 生产恢复范围已从原 Fund-only 扩展到 TRS Fund/Stock;本组继续使用 Fund 夹具, + // 验证共享的登记日/EffectiveDate 边界和回退、平仓基线。 private static readonly DateTime ExDate = new(2026, 8, 17); [TestMethod] @@ -51,15 +53,15 @@ namespace YLErp.Modules.SwapModule } [TestMethod] - public void FCA_UW_002_非Fund和最新Eod后已有完成流水时保持实时持仓() + public void FCA_UW_002_股票与最新Eod后已有完成流水时保持实时持仓() { var nonFund = CreateRealtimeFundPosition(); nonFund.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock; var eod = CreateEod(ExDate, 2000m, 50m); - Assert.IsFalse(SwapEodPositionService.RestoreFundPositionFromEod(nonFund, eod)); - Assert.AreEqual(1000m, nonFund.PosiQuantity); - Assert.AreEqual(100m, nonFund.PosiGrossPrice); + Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(nonFund, eod)); + Assert.AreEqual(2000m, nonFund.PosiQuantity); + Assert.AreEqual(50m, nonFund.PosiGrossPrice); var td = SwapDealTestFactory.CreateTrade(); var realtime = CreateRealtimeFundPosition(); diff --git a/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs b/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs new file mode 100644 index 00000000..022ad4df --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SplitCorporateActionTddTest.cs @@ -0,0 +1,197 @@ +using System.Reflection; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Modules.TradeModule.DealModule; + +namespace YLErp.Modules.SwapModule +{ + [TestClass] + public class SplitCorporateActionTddTest + { + [TestMethod] + public void SplitTenScalesQuantityAndPriceByTen() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(split: 10m); + + Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(1000m, position.PosiQuantity); + Assert.AreEqual(10m, position.PosiGrossPrice); + } + + [TestMethod] + public void SplitPointOneScalesQuantityAndPriceByPointOne() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(split: 0.1m); + + Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(10m, position.PosiQuantity); + Assert.AreEqual(1000m, position.PosiGrossPrice); + } + + [TestMethod] + public void GiveShareTenWithNullSplitUsesCompatibleFactorTwo() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(giveShare: 10m, split: null); + + Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(200m, position.PosiQuantity); + Assert.AreEqual(50m, position.PosiGrossPrice); + } + + [TestMethod] + public void GiveShareFiveAndSplitTwoHaveCombinedFactorThree() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(giveShare: 5m, split: 2m); + + // (1 + 5 / 10) * 2 = 3:100 份/100 元变为 300 份/约 33.333333333 元。 + Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(300m, position.PosiQuantity); + Assert.IsTrue(Math.Abs(position.PosiGrossPrice - 33.333333333m) < 0.000000001m); + } + + [TestMethod] + public void CashAmountDoesNotChangeTrsFundInitialPriceFactor() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction(cash: 10m); + + Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, info, 100m, 0m)); + + Assert.AreEqual(100m, position.PosiQuantity); + Assert.AreEqual(100m, position.PosiGrossPrice); + } + + [TestMethod] + public void RationedSharesUseExcelPriceRatioForTrsQuantity() + { + var position = CreateFundPosition(); + var info = CreateCorporateAction( + rationedSharesAmount: 1m, + rationedSharesPrice: 50m); + + Assert.IsTrue(SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, info, 100m, 0m)); + + // Excel L-N:L=(100*10+1*50)/(10+1)=95.4545...,M=100/L; + // 因此数量和价格分别按 Q'=Q*M、P'=P/M 调整。 + Assert.IsTrue(Math.Abs(position.PosiQuantity - 104.761904761905m) < 0.000000000001m); + Assert.IsTrue(Math.Abs(position.PosiGrossPrice - 95.454545455m) < 0.000000001m); + } + + [TestMethod] + public void ZeroSplitIsRejected() + { + var info = CreateCorporateAction(split: 0m); + + Assert.ThrowsException(() => + SwapEodPositionService.ApplyFundCorporateActionToPosition( + CreateFundPosition(), info, 100m, 0m)); + } + + [TestMethod] + public void NegativeSplitIsRejected() + { + var info = CreateCorporateAction(split: -1m); + + Assert.ThrowsException(() => + SwapEodPositionService.ApplyFundCorporateActionToPosition( + CreateFundPosition(), info, 100m, 0m)); + } + + [TestMethod] + public void MissingSplitDoesNotClearExistingSplitDuringMerge() + { + var target = CreateCorporateAction(split: 10m); + var source = CreateCorporateAction(split: null); + + InvokeMerge(target, source); + + Assert.AreEqual(10m, GetSplit(target)); + } + + [TestMethod] + public void ExplicitSplitOneOverridesExistingSplitDuringMerge() + { + var target = CreateCorporateAction(split: 10m); + var source = CreateCorporateAction(split: 1m); + + InvokeMerge(target, source); + + Assert.AreEqual(1m, GetSplit(target)); + } + + private static ex_dividend_info CreateCorporateAction( + decimal cash = 0m, + decimal giveShare = 0m, + decimal? split = null, + decimal rationedSharesAmount = 0m, + decimal rationedSharesPrice = 0m) + { + var info = new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = new DateTime(2026, 8, 14), + EffectiveDate = new DateTime(2026, 8, 17), + GiveCashAmount = cash, + GiveShareAmount = giveShare, + RationedSharesAmount = rationedSharesAmount, + RationedSharesPrice = rationedSharesPrice, + ValidStatus = true + }; + SetSplit(info, split); + return info; + } + + private static swap_position CreateFundPosition() + { + return new swap_position + { + PosiDirection = 1, + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + UnderlyingCode = "FUND.TEST", + PosiQuantity = 100m, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + PosiNetFeePrice = 100m, + PosiNetNoFeePrice = 100m, + ContractSize = 1m + }; + } + + private static void SetSplit(ex_dividend_info info, decimal? value) + { + var property = typeof(ex_dividend_info).GetProperty("Split"); + Assert.IsNotNull(property, "ex_dividend_info.Split 尚未实现"); + property.SetValue(info, value); + } + + private static decimal? GetSplit(ex_dividend_info info) + { + var property = typeof(ex_dividend_info).GetProperty("Split"); + Assert.IsNotNull(property, "ex_dividend_info.Split 尚未实现"); + return (decimal?)property.GetValue(info); + } + + private static void InvokeMerge(ex_dividend_info target, ex_dividend_info source) + { + var method = typeof(DividendService).GetMethod( + "MergeNonZeroDividendValues", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.IsNotNull(method, "公司行为存量合并方法不存在"); + method.Invoke(null, new object[] { target, source }); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index dbdc42db..9b8e5677 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -332,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); @@ -351,16 +351,18 @@ namespace YLErp.Modules.SwapModule var actual = service.CreatedEodPositions.Single(x => x.PositionId == 1); + // 现金分红改由同步任务写入 bond_payment_info,并以 EffectiveDate 进入债券付息 + // 链路;登记日 EOD 不直接读取 ex_dividend_info,因此此处不应提前产生现金。 Assert.AreEqual(1000m, actual.PosiQuantity); Assert.AreEqual(0m, actual.TdChangedQty); - Assert.AreEqual(99m, actual.PosiGrossPrice); - Assert.AreEqual(1000m, actual.TdPosiDividend); + Assert.AreEqual(100m, actual.PosiGrossPrice); + Assert.AreEqual(0m, actual.TdPosiDividend); Assert.AreEqual(0m, actual.PosiDividendSum); - Assert.AreEqual(99000m, actual.PosiNotionalValue); + Assert.AreEqual(100000m, actual.PosiNotionalValue); Assert.AreEqual(0m, actual.PosiMtmPnL); Assert.AreEqual(0m, actual.PosiProfitSum); - Assert.AreEqual(1000m, actual.RealizedDividend); - Assert.AreEqual(1000m, actual.RealizedPnl); + Assert.AreEqual(0m, actual.RealizedDividend); + Assert.AreEqual(0m, actual.RealizedPnl); } [TestMethod] diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs index f034d731..1809123e 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs @@ -45,6 +45,9 @@ namespace YLErp.Modules.SwapModule /// SwapPositionCompose 使用的公司行为内存数据;默认空,避免测试访问数据库。 public List ExDividendInfos { get; } = new(); + /// 捕获公司行为生命周期事件,避免事件测试访问真实 swap_event 表。 + public List CorporateActionEvents { get; } = new(); + /// 自增 id 模拟器(新增 eod 时分配 id) private int _nextId = 1; @@ -90,6 +93,32 @@ namespace YLErp.Modules.SwapModule .ToList(); } + protected override List FindCorporateActionInfos(DateTime settleDate) + { + return ExDividendInfos + .Where(x => x.ValidStatus + && (x.ExDividendDate?.Date == settleDate.Date + || x.EffectiveDate?.Date == settleDate.Date)) + .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 + .Where(x => x.SwapTradeId == swapTradeId && !x.Invalid + && x.EventType == (int)SwapEventTypeEnum.公司行为) + .ToList(); + } + protected override decimal GetFundCorporateActionClosePrice( ex_dividend_info dividendInfo, decimal fallbackPrice) diff --git a/YLErpDAL/Model/ExDividendInfo.cs b/YLErpDAL/Model/ExDividendInfo.cs index aa4b4c14..3e5305ff 100644 --- a/YLErpDAL/Model/ExDividendInfo.cs +++ b/YLErpDAL/Model/ExDividendInfo.cs @@ -56,6 +56,13 @@ namespace YLErp.DBModels [DisplayName("送股股数")] public decimal GiveShareAmount { get; set; } + + /// + /// 拆/合股倍数。为空时按 1 兼容历史记录;与 GiveShareAmount 的“每 10 份送股数量”语义不同。 + /// + [DisplayName("拆/合股倍数")] + public decimal? Split { get; set; } + /// /// 配股手数 /// diff --git a/YLErpDAL/Modules/EodModule/BondPaymentService.cs b/YLErpDAL/Modules/EodModule/BondPaymentService.cs index a10caab3..8661bdee 100644 --- a/YLErpDAL/Modules/EodModule/BondPaymentService.cs +++ b/YLErpDAL/Modules/EodModule/BondPaymentService.cs @@ -119,24 +119,49 @@ namespace YLErp.Modules.EodModule /// 多空方向 /// 收支方向 /// - public decimal CalcPayment(string underlyingCode, DateTime startDate, DateTime endDate, decimal qty, decimal longRatio, decimal payDirection) + public decimal CalcPayment( + string underlyingCode, + DateTime startDate, + DateTime endDate, + decimal qty, + decimal longRatio, + decimal payDirection, + bool useBondPriceScale = true) { var payments = GetBondPayments(underlyingCode, startDate, endDate); - return CalcPayment(payments, qty, longRatio, payDirection); + return CalcPayment(payments, qty, longRatio, payDirection, useBondPriceScale); } /// - /// 计算某债券期间付息 + /// 计算某标的期间现金流。债券与 Stock/Fund 公司行为共用 bond_payment_info, + /// 但通过 useBondPriceScale 明确区分两种入库金额单位。 /// /// 期间付息集合 /// 持仓数量 /// 多空方向 /// 收支方向 + /// + /// 是否按债券报价的百分比口径换算。债券的 payment_interest 是每 100 元面值的票息, + /// 需要继续通过 BondPriceConverter 转成入库金额;Fund/Stock 的公司行为现金分红 + /// 在 bond_payment_info 中按每 10 份存储,payment_interest * qty 已经是实际现金, + /// 不能再做一次 /100。默认 true 是为了保持所有历史债券调用方的原有口径。 + /// /// - public decimal CalcPayment(List payments, decimal qty, decimal longRatio, decimal payDirection) + public decimal CalcPayment( + List payments, + decimal qty, + decimal longRatio, + decimal payDirection, + bool useBondPriceScale = true) { var interest = payments.Sum(s => s.payment_interest ?? 0); - // interest 为每 100 元面值的票息,×qty 后需 ÷100 转为实际金额(与入库价格 bondPriceMultiple 同口径) - return BondPriceConverter.ToStorage(interest * qty) * longRatio * payDirection; + var paymentAmount = interest * qty; + // 债券:interest 为每 100 元面值的票息,×qty 后需 ÷100 转为实际金额。 + // Fund/Stock 公司行为:interest 已由【同步任务】写成 GiveCashAmount/10, + // ×qty 就是“每 10 份派现额 × 持仓份额”,必须保留原金额,不能套债券的 /100。 + var actualAmount = useBondPriceScale + ? BondPriceConverter.ToStorage(paymentAmount) + : paymentAmount; + return actualAmount * longRatio * payDirection; } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 4a376890..be443144 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -2005,10 +2005,20 @@ namespace YLErp.Modules.SwapModule int shortRatio = DirectionRatio.LongShort(flowEvent.PositionType); int directionRatio = DirectionRatio.ReceivePay(flowEvent.PayDirection); - // + 付息日>上日日终且小于等于平仓日期的分红数据 - var dividendIn = servie.CalcPayment(payments, unwindQty, shortRatio, directionRatio); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(flowEvent.UnderlyingCode); - decimal tax = um.ValueAddedTax ?? 0; + // 债券付息按每百元票息存储,继续走 BondPriceConverter;Stock/Fund 的公司行为 + // 现金分红按每 10 份金额存储,实际现金就是 payment_interest * qty,不能 /100。 + // 标的资料缺失时保持旧债券口径,避免未知标的的历史平仓金额被放大。 + var useBondPriceScale = um == null + || !SwapEodPositionService.IsCorporateActionInstrument(um.UnderlyingInstrumentType); + // + 付息日>上日日终且小于等于平仓日期的分红数据 + var dividendIn = servie.CalcPayment( + payments, + unwindQty, + shortRatio, + directionRatio, + useBondPriceScale); + decimal tax = um?.ValueAddedTax ?? 0; dividendIn = DividendCalc.AfterTaxRaw(dividendIn, tax); flowEvent.DividendIn = Math.Round(dividendIn, 2, MidpointRounding.AwayFromZero); diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 0fb9afa3..af6b23ad 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -91,8 +91,8 @@ namespace YLErp.Modules.SwapModule if (realtimePosition == null || eodPosition == null || realtimePosition.PosiDirection <= 0 - || realtimePosition.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund - || eodPosition.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund) + || !IsTrsCorporateActionInstrument(realtimePosition.UnderlyingInstrumentType) + || !IsTrsCorporateActionInstrument(eodPosition.UnderlyingInstrumentType)) { return false; } @@ -370,10 +370,27 @@ namespace YLErp.Modules.SwapModule return DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode); } - /// 计算债券付息(生产: BondPaymentService;测试: 返回固定值) + /// + /// 计算期间现金流(生产: BondPaymentService;测试: 返回固定值)。 + /// BondPaymentService 的默认仍是债券百分比价格口径;TRS Stock/Fund 的公司行为 + /// 分红行按每 10 份金额入库,因此必须显式关闭 BondPriceConverter 的 /100 换算。 + /// 标的资料缺失时沿用债券口径,避免把未知历史数据放大 100 倍。 + /// protected virtual decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) { - return new BondPaymentService(UserInfo).CalcPayment(underlyingCode, fromDate, toDate, qty, shortRatio, directionRatio); + var underlying = GetUnderlyingData(underlyingCode); + // 本期现金分红只覆盖 TRS Stock/Fund。其他非债券(期货、期权等)虽然也不属于 + // 债券,但尚未接入本现金分红表,继续使用默认债券换算,避免扩大改造范围。 + var useBondPriceScale = underlying == null + || !IsCorporateActionInstrument(underlying.UnderlyingInstrumentType); + return new BondPaymentService(UserInfo).CalcPayment( + underlyingCode, + fromDate, + toDate, + qty, + shortRatio, + directionRatio, + useBondPriceScale); } // ---- SwapPositionCompose 路径专用 seam(借鉴 testable 分支)---- @@ -444,6 +461,48 @@ namespace YLErp.Modules.SwapModule .ToList(); } + /// + /// 查询登记日或真实生效日命中的公司行为。保留 FindExDividendInfos 这个 + /// 可替换入口,测试和历史调用方可以继续注入内存数据。 + /// + protected virtual List FindCorporateActionInfos(DateTime settleDate) + { + return DbContext.ex_dividend_info + .Where(x => x.ValidStatus + && ((x.ExDividendDate.HasValue && x.ExDividendDate.Value == settleDate.Date) + || (x.EffectiveDate.HasValue && x.EffectiveDate.Value == settleDate.Date))) + .ToList(); + } + + /// 查询交易已有公司行为事件,用于登记日/生效日幂等匹配。 + protected virtual List FindCorporateActionEvents(int swapTradeId) + { + return DbContext.swap_event + .Where(x => x.SwapTradeId == swapTradeId + && x.EventType == (int)SwapEventTypeEnum.公司行为 + && !x.Invalid) + .ToList(); + } + + /// 更新已存在的公司行为事件;默认只标记实体,统一由收盘事务保存。 + protected virtual void UpdateCorporateActionEventRecord(swap_event swapEvent) + { + 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 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日 @@ -474,6 +533,17 @@ namespace YLErp.Modules.SwapModule return new DividendService(this).GetDividendTaxRateDecimal(); } + public static bool IsCorporateActionInstrument(string instrumentType) + { + // TRS 公司行为本期只覆盖 Stock/Fund。TBonds 等类型继续走原债券付息链路, + // 这里不能用“非空标的类型”放宽,否则会把期权、期货等未验证品种一并启用。 + return string.Equals(instrumentType, ConsGlobal.InstrumentType.Fund, StringComparison.OrdinalIgnoreCase) + || string.Equals(instrumentType, ConsGlobal.InstrumentType.Stock, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTrsCorporateActionInstrument(string instrumentType) + => IsCorporateActionInstrument(instrumentType); + #endregion /// @@ -522,7 +592,19 @@ namespace YLErp.Modules.SwapModule var completedFlowEvents = FindCompletedFlowEvents(tradeIds); // 公司行为只取 settleDate 当天的有效单行;同一标的出现多条记录必须中止本次收盘, // 否则 ToDictionary 会抛重复键,无法证明哪一条系数应生效。 - var exDividendInfos = FindExDividendInfos(settleDate); + var corporateActionInfos = FindCorporateActionInfos(settleDate) ?? new List(); + var exDividendInfos = corporateActionInfos + .Where(x => x != null + && x.ValidStatus + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value.Date == settleDate.Date) + .ToList(); + var registrationInfos = corporateActionInfos + .Where(x => x != null + && x.ValidStatus + && x.ExDividendDate.HasValue + && x.ExDividendDate.Value.Date == settleDate.Date) + .ToList(); var duplicateDividend = exDividendInfos .GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(x => x.Count() > 1); @@ -530,6 +612,16 @@ namespace YLErp.Modules.SwapModule { throw new InvalidOperationException($"标的【{duplicateDividend.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效除权记录"); } + // 公司行为去重 - 拦截 + var duplicateRegistration = registrationInfos + .GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(x => x.Count() > 1); + if (duplicateRegistration != null) + { + // 登记日现金权益不能依赖数据库返回顺序取 First;同一标的同一登记日 + // 有多条有效记录时,系统无法证明应采用哪一条派现金额,必须中止收盘。 + throw new InvalidOperationException($"标的【{duplicateRegistration.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效登记日记录"); + } var exDividendByCode = exDividendInfos.ToDictionary( x => x.UnderlyingCode, x => x, @@ -573,17 +665,32 @@ namespace YLErp.Modules.SwapModule var flowEvents = FindFlowEvents(td.id, settleDate); var preDealDate = GetPreDealDate(td.id, settleDate, eventTyps);//上一次平仓/互换/自动互换处理日期 List autoInterests = new List();//自动互换利息腿信息 - // 处理浮动腿前先准备当日开盘基线:登记日 8 月 14 日 EOD 仍保存 - // 1000 份/100 元,8 月 17 日收盘时先把上一 EOD 的基线转换为 + // 处理浮动腿前先准备当日开盘基线:登记日 EOD 仍保存 + // 1000 份/100 元,除权日收盘时先把上一 EOD 的基线转换为 // 2000 份/50 元,再处理当日平仓 300 份,最终才会得到 1700 份/50 元。 // 不能等 DealFloatPositions 处理完平仓后再把 700 份乘 2,否则会错误得到 // 1400 份;也不能直接修改数据库里的上一 EOD,否则登记日报表会被污染。 + + // 重置基线 var openingEodPositions = PrepareFundOpeningEodPositions( eodPositions, exDividendByCode, settleDate); + + // 构建公司行为前eod持仓 + var corporateActionBeforePositions = BuildCorporateActionBeforePositions( + eodPositions, + posiList); + + // 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线,应用生效日公司行为。 + // 有上一份 EOD 时沿用 PrepareFundOpeningEodPositions,避免重复套系数。 + var floatPositionsForCompose = eodPositions.Count == 0 + ? PrepareInitialCorporateActionPositions(posiList, exDividendByCode, settleDate) + : posiList; + + // 处理浮动腿归档 var curEodPosis = DealFloatPositions( - posiList, + floatPositionsForCompose, realPosiList, openingEodPositions, todyEodPositions, @@ -591,14 +698,17 @@ namespace YLErp.Modules.SwapModule td, preSettleDate, flowEvents); - // 公司行为 - 分红 - // Fund 现金分红在 EffectiveDate 当日收盘即完成结算: - // TdPosiDividend 展示当日金额,RealizedDividend 累计已实现金额, - // 不把同一笔金额留在 PosiDividendSum 待实现字段中。 - ApplyFundCashDividends( + + // 现金分红不在登记日直接读取 ex_dividend_info 累加。 + // 同步任务会把 GiveCashAmount/10 写入 bond_payment_info,Copy/Update EOD 在 + // EffectiveDate 通过 CalcBondPayment 命中该行并生成 TdPosiDividend。 + // 这样登记日快照不提前变化,也不会与债券付息/平仓链路重复计算。 + RecordCorporateActionEvents( + td, curEodPosis, - eodPositions, - exDividendByCode, + corporateActionBeforePositions, + 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); @@ -665,11 +775,11 @@ namespace YLErp.Modules.SwapModule } /// - /// 对 Fund 浮动腿应用一条已按 EffectiveDate 筛选的公司行为。 + /// 对 TRS Stock/Fund 浮动腿应用一条已按 EffectiveDate 筛选的份额/价格公司行为。 /// 此方法用于直接测试/兼容已有调用方;正式收盘链路通过 /// PrepareFundOpeningEodPositions 在处理当日流水前执行同一动作。 - /// 该步骤只改 EOD 持仓,不生成现金分红流水;现金分红通过期初价下调进入浮动端损益, - /// 若同时再写 TdPosiDividend 会重复计入。 + /// 该步骤只改 EOD 持仓的份额/价格基线,不生成现金分红流水;现金模式下现金分红 + /// 不下调期初价格,而是由同步任务写入 bond_payment_info,后续付息链路单独计入。 /// /// 幂等例子:原持仓 1000 份、期初价 100,每 10 份送 10 份。首次收盘得到 2000 份/50; /// 同日重跑时,若该腿没有新流水,先从前一日 EOD 恢复 1000/100,再计算为 2000/50, @@ -698,7 +808,7 @@ namespace YLErp.Modules.SwapModule foreach (var position in positions) { if (position.PosiDirection <= 0 - || position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType) || string.IsNullOrWhiteSpace(position.UnderlyingCode) || !exDividendByCode.TryGetValue(position.UnderlyingCode, out var dividendInfo) || !dividendInfo.EffectiveDate.HasValue @@ -739,22 +849,25 @@ namespace YLErp.Modules.SwapModule $"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】的除权份额参数导致除数为 0"); } + // 计算除权系数 var factors = DividendService.CalculateCorporateActionFactors( dividendInfo, corporateActionClosePrice, - dividendTaxRate); - if (factors.PriceRatio <= 0 || factors.ShareFactor <= 0) + dividendTaxRate, + adjustCashDividendPrice: false); + if (factors.PriceRatio <= 0) { throw new InvalidOperationException( $"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】计算得到无效除权系数"); } - // PriceRatio 是“除权前收盘价 / 除权参考价”,所以期初价格要除以它;ShareFactor - // 只来自送股/拆合股。10 送 10 时 1000 份/100 变为 2000 份/50,名义本金仍为 100000; - // 每 10 份派现 10 时数量不变、价格基准降为 99,名义本金变为 99000,后续平一半只能扣 49500。 + // Excel 公式 口径:PriceRatio 是“登记日收盘价 / 除权参考价”, + // 因此期初价格和持仓数量都使用同一个系数:P' = P / M,Q' = Q * M。 + // 配股已经进入 价格参考价,所以即使没有送股,配股也会调整 TRS 数量; + // 现金分红不影响 TRS Stock/Fund 期初价格,现金权益由独立分红字段处理。 var originalQuantity = position.PosiQuantity; position.PosiQuantity = Math.Round( - originalQuantity * factors.ShareFactor, + originalQuantity * factors.PriceRatio, 12, MidpointRounding.AwayFromZero); position.TdChangedQty = position.PosiQuantity - originalQuantity; @@ -815,10 +928,297 @@ namespace YLErp.Modules.SwapModule } /// - /// 将 Fund 当日现金分红记入 EOD 已实现分红。 + /// 构造审计事件的调整前快照。优先克隆上一 EOD,保证后续调整不会污染历史实体; + /// 交易首日没有 EOD 时才从初始持仓复制,并把累计分红/已实现字段初始化为 0。 + /// + private static List BuildCorporateActionBeforePositions( + IReadOnlyCollection previousPositions, + IReadOnlyCollection initialPositions) + { + if (previousPositions != null && previousPositions.Count > 0) + { + return previousPositions + .Where(x => x != null) + .Select(x => x.Clone()) + .ToList(); + } + + return (initialPositions ?? Array.Empty()) + .Where(x => x != null) + .Select(x => new eod_swap_position + { + PositionId = x.PositionId, + UnderlyingCode = x.UnderlyingCode, + UnderlyingInstrumentType = x.UnderlyingInstrumentType, + PosiDirection = x.PosiDirection, + PositionType = x.PositionType, + ContractSize = x.ContractSize, + CountRatio = x.CountRatio, + PosiQuantity = x.PosiQuantity, + PosiGrossPrice = x.PosiGrossPrice, + PosiNetPrice = x.PosiNetPrice, + PosiNetFeePrice = x.PosiNetFeePrice, + PosiNetNoFeePrice = x.PosiNetNoFeePrice, + PosiNotionalValue = x.PosiNotionalValue, + PosiTradingFee = x.PosiTradingFee, + PosiFeePending = x.PosiTradingFeePending, + PosiDividendSum = 0m, + RealizedDividend = 0m, + PosiStatus = x.PosiQuantity == 0m ? 1 : 0 + }) + .ToList(); + } + + /// + /// 交易首日恰逢 EffectiveDate 时,在内存克隆上生成除权后的开盘基线。 + /// 不直接修改初始持仓实体,避免重收盘或后续流程再次读取时重复套用系数。 + /// + private List PrepareInitialCorporateActionPositions( + IReadOnlyCollection initialPositions, + IReadOnlyDictionary exDividendByCode, + DateTime settleDate) + { + var positions = (initialPositions ?? Array.Empty()) + .Where(x => x != null) + .Select(x => x.Clone()) + .ToList(); + if (positions.Count == 0 || exDividendByCode == null || exDividendByCode.Count == 0) + { + return positions; + } + + foreach (var position in positions) + { + if (position.PosiDirection <= 0 + || !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType) + || string.IsNullOrWhiteSpace(position.UnderlyingCode) + || !exDividendByCode.TryGetValue(position.UnderlyingCode, out var info)) + { + continue; + } + + var closePrice = GetFundCorporateActionClosePrice(info, position.PosiGrossPrice); + ApplyFundCorporateActionToPosition( + position, + info, + closePrice, + GetDividendTaxRate()); + } + + return positions; + } + + /// + /// 写入公司行为生命周期审计事件。 + /// 登记日:保存调整前快照并标记 Applied=false; + /// 真实除权日:使用上一 EOD 与当前 EOD 补齐调整后快照并标记 Applied=true。 + /// 事件数据只追加/补齐,不删除已生效记录, + /// 便于交易回退后通过 BackId 关联新的回退记录。 + /// + protected virtual void RecordCorporateActionEvents( + trade td, + IReadOnlyCollection currentPositions, + IReadOnlyCollection previousPositions, + IReadOnlyCollection registrationInfos, + IReadOnlyCollection effectiveInfos, + DateTime settleDate) + { + if (td == null || currentPositions == null) + { + return; + } + + var infos = (registrationInfos ?? Array.Empty()) + .Concat(effectiveInfos ?? Array.Empty()) + .Where(x => x != null && x.ValidStatus && !string.IsNullOrWhiteSpace(x.UnderlyingCode)) + .GroupBy(x => new + { + x.id, + x.UnderlyingCode, + ExDividendDate = x.ExDividendDate?.Date, + EffectiveDate = x.EffectiveDate?.Date + }) + .Select(x => x.First()) + .ToList(); + if (infos.Count == 0) + { + return; + } + + var existingEvents = FindCorporateActionEvents(td.id); + foreach (var current in currentPositions.Where(x => x != null && x.PosiDirection > 0 + && IsTrsCorporateActionInstrument(x.UnderlyingInstrumentType))) + { + var info = infos.FirstOrDefault(x => string.Equals( + x.UnderlyingCode, + current.UnderlyingCode, + StringComparison.OrdinalIgnoreCase)); + if (info == null) + { + continue; + } + + // 公司行为事件只使用“公司行为记录主键 + PositionId”作为幂等键。 + var matchingEvents = existingEvents + .Select(x => new { Event = x, Data = DeserializeCorporateActionEventData(x.EventData) }) + .Where(x => x.Data != null + && info.id > 0 + && x.Data.ExDividendInfoId == info.id + && x.Data.PositionId == current.PositionId) + .ToList(); + var eventData = matchingEvents.FirstOrDefault(x => !x.Data.Applied) + ?? matchingEvents.FirstOrDefault(); + var previous = previousPositions?.FirstOrDefault(x => x != null && x.PositionId == current.PositionId); + // 登记日 false 除权日 true + var isEffective = info.EffectiveDate.HasValue + && info.EffectiveDate.Value.Date <= settleDate.Date + && effectiveInfos != null + && effectiveInfos.Any(x => x.id == info.id); + + // 如果没有匹配到事件或事件未生效,则创建新事件。 + if (eventData == null || (!isEffective && eventData.Data.Applied)) + { + // 创建新事件 + var pending = BuildCorporateActionEventData( + info, + previous ?? current, + isEffective ? current : null, + applied: isEffective); + // 生命周期事件的发生日固定为登记日,EffectiveDate 只表示 Q/P 基线切换日。 + // 这样回退后重收盘仍能按原登记日排序和追溯,不会把同一事件拆成两条历史。 + var eventDate = info.ExDividendDate?.Date + ?? info.EffectiveDate?.Date + ?? settleDate.Date; + var created = AddSwapEvent( + eventDate, + td.id, + (int)SwapEventTypeEnum.公司行为, + JsonConvert.SerializeObject(pending), + 0, + false, + BuildCorporateActionReason(pending)); + if (created == null) + { + created = new swap_event(); + } + // 测试接缝和历史实现可能返回只带 id 的实体;统一补齐字段, + // 确保同一收盘事务内的生效步骤能找到刚创建的事件。 + created.EventType = (int)SwapEventTypeEnum.公司行为; + created.SwapTradeId = td.id; + created.ValueDate = eventDate; + created.EventData = JsonConvert.SerializeObject(pending); + created.EventReason = BuildCorporateActionReason(pending); + existingEvents.Add(created); + continue; + } + + // 如果不是生效日或事件已生效,则跳过。 + if (!isEffective || eventData.Data.Applied) + { + continue; + } + + // 生效日只补齐同一事件的 Before/After 快照,不重新套系数:Before* 来自 + // 调整前 EOD,After* 来自生效日当前 EOD,current 已由开盘基线处理完成。 + eventData.Data.BeforeNotional = previous?.PosiNotionalValue ?? eventData.Data.BeforeNotional; + eventData.Data.BeforePrice = previous?.PosiGrossPrice ?? eventData.Data.BeforePrice; + eventData.Data.BeforeQuantity = previous?.PosiQuantity ?? eventData.Data.BeforeQuantity; + eventData.Data.BeforePendingDividend = previous?.PosiDividendSum ?? eventData.Data.BeforePendingDividend; + eventData.Data.AfterNotional = current.PosiNotionalValue; + eventData.Data.AfterPrice = current.PosiGrossPrice; + eventData.Data.AfterQuantity = current.PosiQuantity; + eventData.Data.AfterPendingDividend = current.PosiDividendSum; + eventData.Data.CashFlowChange = current.RealizedDividend - (previous?.RealizedDividend ?? current.RealizedDividend); + eventData.Data.Applied = true; + eventData.Event.EventData = JsonConvert.SerializeObject(eventData.Data); + eventData.Event.EventReason = BuildCorporateActionReason(eventData.Data); + UpdateCorporateActionEventRecord(eventData.Event); + } + } + + public static CorporateActionEventData BuildCorporateActionEventData( + ex_dividend_info info, + eod_swap_position previous, + eod_swap_position current, + bool applied) + { + return new CorporateActionEventData + { + ExDividendInfoId = info.id, + PositionId = (current ?? previous).PositionId, + UnderlyingCode = (current ?? previous).UnderlyingCode, + ExDividendDate = info.ExDividendDate, + EffectiveDate = info.EffectiveDate, + GiveCashAmount = info.GiveCashAmount, + GiveShareAmount = info.GiveShareAmount, + Split = info.Split, + RationedSharesAmount = info.RationedSharesAmount, + RationedSharesPrice = info.RationedSharesPrice, + BeforeNotional = previous?.PosiNotionalValue ?? 0m, + BeforePrice = previous?.PosiGrossPrice ?? 0m, + BeforeQuantity = previous?.PosiQuantity ?? 0m, + AfterNotional = applied ? current?.PosiNotionalValue ?? 0m : 0m, + AfterPrice = applied ? current?.PosiGrossPrice ?? 0m : 0m, + AfterQuantity = applied ? current?.PosiQuantity ?? 0m : 0m, + BeforePendingDividend = previous?.PosiDividendSum ?? 0m, + AfterPendingDividend = applied ? current?.PosiDividendSum ?? 0m : 0m, + CashFlowChange = applied ? (current?.RealizedDividend ?? 0m) - (previous?.RealizedDividend ?? 0m) : 0m, + Applied = applied, + }; + } + + public static bool ShouldCreateCorporateActionEvent( + IEnumerable events, + ex_dividend_info info, + long positionId) + { + if (info == null) + { + return false; + } + + // 幂等键与收盘事件匹配保持一致,只认 ExDividendInfoId + PositionId。 + // 无法反序列化或缺少 ExDividendInfoId 的存量事件均不参与匹配。 + return !(events ?? Enumerable.Empty()).Any(x => + { + if (!SwapEventService.TryDeserializeCorporateActionEventData(x, out var data)) + { + return false; + } + return info.id > 0 + && data.ExDividendInfoId == info.id + && data.PositionId == positionId; + }); + } + + private static CorporateActionEventData DeserializeCorporateActionEventData(string eventData) + { + if (string.IsNullOrWhiteSpace(eventData)) + { + return null; + } + try + { + return JsonConvert.DeserializeObject(eventData); + } + catch (JsonException) + { + return null; + } + } + + private static string BuildCorporateActionReason(CorporateActionEventData data) + { + return SwapEventService.BuildCorporateActionEventReason(data); + } + + /// + /// 兼容旧测试/扩展调用的直接现金分红辅助方法。 /// GiveCashAmount 按每 10 份金额计算:1000 份、每 10 份派 10,结果为 1000。 - /// 现金分红在生效日 EOD 即执行,因此 PosiDividendSum 不增加本次金额, - /// 同时从除权价格变化产生的 PosiMtmPnL 中剥离,避免收益重复计算。 + /// 当前生产 SwapPositionCompose 不再调用此方法:公司行为现金分红由同步任务 + /// 写入 bond_payment_info,EffectiveDate 收盘通过 CalcBondPayment 进入 EOD, + /// 以避免登记日提前入账及与债券付息链路重复。保留方法是为了不破坏已有测试替身 + /// 或外部扩展类的编译契约;新增业务代码不得再直接传入 ex_dividend_info。 /// protected void ApplyFundCashDividends( IReadOnlyCollection currentEodPositions, @@ -827,7 +1227,6 @@ namespace YLErp.Modules.SwapModule DateTime settleDate) { if (currentEodPositions == null - || previousEodPositions == null || exDividendByCode == null || exDividendByCode.Count == 0) { @@ -835,20 +1234,21 @@ namespace YLErp.Modules.SwapModule } var dividendTaxRate = GetDividendTaxRate(); + var previousList = previousEodPositions ?? Array.Empty(); foreach (var current in currentEodPositions) { if (current == null || current.PosiDirection == 0 - || current.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || !IsTrsCorporateActionInstrument(current.UnderlyingInstrumentType) || string.IsNullOrWhiteSpace(current.UnderlyingCode) || !exDividendByCode.TryGetValue(current.UnderlyingCode, out var dividendInfo) - || !dividendInfo.EffectiveDate.HasValue - || dividendInfo.EffectiveDate.Value.Date != settleDate.Date) + || !dividendInfo.ExDividendDate.HasValue + || dividendInfo.ExDividendDate.Value.Date != settleDate.Date) { continue; } - var previous = previousEodPositions.FirstOrDefault( + var previous = previousList.FirstOrDefault( x => x != null && x.PositionId == current.PositionId); var entitlementQuantity = previous?.PosiQuantity ?? current.PosiQuantity; var directionRatio = DirectionRatio.ReceivePay(current.PosiDirection); @@ -859,18 +1259,16 @@ namespace YLErp.Modules.SwapModule * directionRatio : 0m; - // 当日浮动端分红 + // 当日浮动端分红。公司行为现金分红采用现金模式:不调期初价格, + // 只增加待实现分红,支付日仍由既有 DealDividends/付息链路结算。 current.TdPosiDividend = RoundMoney(currentDividend); var previousDividendSum = previous?.PosiDividendSum ?? 0m; - // 浮动端平仓盈亏·分红未实现 = 未实现分红总和 - 当日浮动端平仓盈亏·分红 + // 浮动端平仓盈亏·分红未实现 = 前日待实现 + 当日公司行为分红 + // - 当日已实现分红;本次公司行为尚未支付,因此不能写入 RealizedDividend。 current.PosiDividendSum = current.PosiQuantity > 0m - ? RoundMoney(previousDividendSum - current.TdCloseDividend) + ? RoundMoney(previousDividendSum + current.TdPosiDividend - current.TdCloseDividend) : 0m; - // 浮动端平仓盈亏·盯市未实现 = 盯市未实现 - 当日浮动端分红 - // current.PosiMtmPnL = RoundMoney(current.PosiMtmPnL - current.TdPosiDividend); - // 浮动端已实现·分红 = 已实现分红 + 当日浮动端分红 - current.RealizedDividend = RoundMoney(current.RealizedDividend + current.TdPosiDividend); - // 浮动端已实现·盈亏 = 盈亏 + 当日浮动端分红 + // 现金模式不从 PosiMtmPnL 剥离分红:价格没有被除权,分红只存在于待实现字段。 current.PosiProfitSum = RoundMoney(MtmCalc.ReturnLegProfitSum( current.PosiMtmPnL, current.PosiDividendSum, @@ -883,10 +1281,12 @@ namespace YLErp.Modules.SwapModule } /// - /// 将一条真实生效日公司行为应用到盘中实时 Fund 浮动腿。 + /// 将一条真实生效日公司行为应用到盘中实时 TRS Stock/Fund 浮动腿。 /// 盘中先复制严格早于 valueDate 的 EOD,再调用此方法;因此重复调用时每次都会 /// 从同一份除权前 EOD 重新恢复,不会把 1000/100 重复变成 4000/25。 /// 例:8 月 14 日 EOD 为 1000/100,8 月 17 日生效的 10 送 10 会得到 2000/50。 + /// 现金模式调用公式时使用 adjustCashDividendPrice=false,现金权益只进入分红字段, + /// 不改变 Stock/Fund 的期初价格。 /// public static bool ApplyFundCorporateActionToPosition( swap_position position, @@ -897,7 +1297,7 @@ namespace YLErp.Modules.SwapModule if (position == null || dividendInfo == null || position.PosiDirection <= 0 - || position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || !IsTrsCorporateActionInstrument(position.UnderlyingInstrumentType) || corporateActionClosePrice <= 0) { return false; @@ -906,8 +1306,9 @@ namespace YLErp.Modules.SwapModule var factors = DividendService.CalculateCorporateActionFactors( dividendInfo, corporateActionClosePrice, - dividendTaxRate); - if (factors.PriceRatio <= 0 || factors.ShareFactor <= 0) + dividendTaxRate, + adjustCashDividendPrice: false); + if (factors.PriceRatio <= 0) { throw new InvalidOperationException( $"Fund 标的【{position.UnderlyingCode}】计算得到无效除权系数"); @@ -915,7 +1316,7 @@ namespace YLErp.Modules.SwapModule var originalQuantity = position.PosiQuantity; position.PosiQuantity = Math.Round( - originalQuantity * factors.ShareFactor, + originalQuantity * factors.PriceRatio, 12, MidpointRounding.AwayFromZero); position.PosiGrossPrice = Math.Round( @@ -1203,9 +1604,10 @@ namespace YLErp.Modules.SwapModule var hasDividend = curEodPositions.Any(x => x.PosiDividendSum != 0); if (!hasDividend) return; - // ApplyFundCorporateActions 已经把 Fund 的现金分红写入除权后的期初价格/名义本金; - // 这里处理的是持仓期间累计的付息/分红结算流水。两者同时把同一现金再写入 - // PosiDividendSum 会重复实现,故公司行为步骤不会在此处直接填充该字段。 + // 公司行为现金分红与债券付息共用既有待实现/支付链路:公司行为步骤只把金额 + // 累加到 PosiDividendSum,这里仍按交易约定的 DividendPayDate 生成支付流水。 + // 公司行为不会调整 Stock/Fund 的期初价格;因此不能再把现金分红从 PosiMtmPnL + // 中剥离或当作已实现收益提前写入。 var dividendPayDateOffset = tradeExtend?.ExtendObj?.DividendPayDate ?? 1; if (dividendPayDateOffset <= 0) return; diff --git a/YLErpDAL/Modules/SwapModule/SwapEventService.cs b/YLErpDAL/Modules/SwapModule/SwapEventService.cs index 782b83e1..b9be5178 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEventService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEventService.cs @@ -1,6 +1,7 @@ using Newtonsoft.Json; using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Linq.Expressions; using System.Text; @@ -206,14 +207,120 @@ namespace YLErp.Modules.SwapModule return events; } /// - /// 获取交易操作历史 + /// 获取交易操作历史。返回前会过滤掉登记日创建且尚未应用(Applied=false) + /// 的公司行为事件,避免交易详情在真实调整前展示一条已完成历史。 /// /// 交易id /// public List GetOpreationHistorys(int tradeId) { - List list = DbContext.swap_event.Where(x => x.SwapTradeId == tradeId).OrderByDescending(o => o.id).ToList(); - return list; + List list = DbContext.swap_event + .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; + } + + /// + /// 将 swap_event.EventData 安全反序列化为公司行为快照。事件为空、EventData + /// 为空白或 JSON 格式不匹配时返回 false 并将 data 置 null,调用方据此保留旧格式记录。 + /// + public static bool TryDeserializeCorporateActionEventData( + swap_event swapEvent, + out CorporateActionEventData data) + { + data = null; + if (swapEvent == null || string.IsNullOrWhiteSpace(swapEvent.EventData)) + { + return false; + } + + try + { + data = JsonConvert.DeserializeObject(swapEvent.EventData); + return data != null; + } + catch (JsonException) + { + return false; + } + } + + /// + /// 公司行为说明使用稳定的键值格式,完整保留调整前后名义本金、价格、数量、 + /// 待实现分红和现金流变化,操作历史无需重新计算即可核对。 + /// + public static string BuildCorporateActionEventReason(CorporateActionEventData data) + { + if (data == null) + { + return "公司行为快照为空"; + } + + // 使用 InvariantCulture 固定小数与日期格式,说明文本不随服务器区域设置变化。 + string D(decimal value) => value.ToString(CultureInfo.InvariantCulture); + string Date(DateTime? value) => value.HasValue + ? value.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + : ""; + + return string.Join("; ", new[] + { + $"公司行为[{data.UnderlyingCode}]", + $"ExDividendDate={Date(data.ExDividendDate)}", + $"EffectiveDate={Date(data.EffectiveDate)}", + $"ExDividendInfoId={data.ExDividendInfoId}", + $"PositionId={data.PositionId}", + $"GiveCashAmount={D(data.GiveCashAmount)}", + $"GiveShareAmount={D(data.GiveShareAmount)}", + $"Split={(data.Split.HasValue ? D(data.Split.Value) : "")}", + $"RationedSharesAmount={D(data.RationedSharesAmount)}", + $"RationedSharesPrice={D(data.RationedSharesPrice)}", + "调整前", + $"BeforeNotional={D(data.BeforeNotional)}", + $"BeforePrice={D(data.BeforePrice)}", + $"BeforeQuantity={D(data.BeforeQuantity)}", + $"BeforePendingDividend={D(data.BeforePendingDividend)}", + "调整后", + $"AfterNotional={D(data.AfterNotional)}", + $"AfterPrice={D(data.AfterPrice)}", + $"AfterQuantity={D(data.AfterQuantity)}", + $"AfterPendingDividend={D(data.AfterPendingDividend)}", + $"CashFlowChange={D(data.CashFlowChange)}", + $"Applied={data.Applied}" + }); } public void DeleteEvent(int tradeId) diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index 52e14f22..1198ab79 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -1547,8 +1547,25 @@ namespace YLErp.Modules.SwapModule { swapEventService.DeleteExtensionTime(swapEvent.id); } + var corporateActionEvents = DbContext.swap_event + .Where(x => !x.Invalid + && x.SwapTradeId == tradeId + && x.EventType == (int)SwapEventTypeEnum.公司行为 + && x.ValueDate >= valueDate) + .OrderByDescending(x => x.id) + .ToList(); InvalidTradeOptionDatasByDate(tradeId, valueDate, backToBegin); - swapEventService.AddSwapEventDate(valueDate, tradeId, (int)SwapEventTypeEnum.回退, string.Empty, 0, false, $"交易回退至{valueDate:yyyy年MM月dd日}"); + var rollbackEvent = swapEventService.AddSwapEventDate( + valueDate, + tradeId, + (int)SwapEventTypeEnum.回退, + string.Empty, + 0, + false, + $"交易回退至{valueDate:yyyy年MM月dd日}"); + // 公司行为原事件保持有效作为不可篡改审计;回退事件通过 BackId 指向本次 + // 回退影响的最新公司行为事件,后续重收盘会追加新的公司行为事件。 + rollbackEvent.BackId = corporateActionEvents.FirstOrDefault()?.id ?? 0; DbContext.SaveChanges(); if (del) { @@ -1762,6 +1779,12 @@ namespace YLErp.Modules.SwapModule var firstConfirm = false; swapEvents.ForEach(x => { + // 公司行为事件是不可篡改审计日志。回退只追加回退事件,不把原始公司 + // 行为事件置无效;否则无法追溯交易曾经经历过的调整。 + if (x.EventType == (int)SwapEventTypeEnum.公司行为) + { + return; + } if (backToBegin && !firstConfirm && x.EventType == (int)SwapEventTypeEnum.确认交易) { firstConfirm = true; diff --git a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs index 598b89c5..1390a183 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs @@ -748,44 +748,68 @@ namespace YLErp.Modules.TradeModule.DealModule internal readonly struct CorporateActionFactors { - public CorporateActionFactors(decimal priceRatio, decimal shareFactor) + public CorporateActionFactors(decimal priceRatio) { PriceRatio = priceRatio; - ShareFactor = shareFactor; } public decimal PriceRatio { get; } - public decimal ShareFactor { get; } } /// - /// 统一计算公司行为的价格系数和数量系数。价格系数沿用原股票除权公式; - /// 数量仅受送股/拆合股影响,配股仍只进入价格公式,保持现有业务口径不变。 + /// 按 Excel 公式计算公司行为的除权系数。 + /// GiveShareAmount 只表示每 10 份的送股数量,Split 表示独立的拆/合股倍数; + /// Split 为空按 1 兼容历史记录。TRS Stock/Fund 使用 PriceRatio 同时调整期初价格 + /// 和持仓数量,不再维护独立的旧数量系数。 /// - /// 送股例子:收盘价 100、每 10 份送 10 份、无现金/配股时,除权参考价为 50, - /// PriceRatio=100/50=2,ShareFactor=2。调用方据此把 1000 份/期初价 100 调整为 - /// 2000 份/50;数量与价格反向变化,期初名义本金仍为 100000。 - /// - /// - /// 现金例子:收盘价 100、每 10 份派现 10、税率 0 时,除权参考价为 99, - /// ShareFactor 仍为 1,所以数量不变,只把期初价按 100/99 的价格系数下调。 + /// 现金分红不参与 TRS Stock/Fund 的期初价格公司行为系数;现金权益由既有分红流水单独处理。 /// 本方法只返回系数,不修改持仓,也不判断公司行动是否已经执行;幂等边界由调用方保证。 /// /// internal static CorporateActionFactors CalculateCorporateActionFactors( ex_dividend_info info, decimal closePrice, - decimal dividendRate) + decimal dividendRate, + bool adjustCashDividendPrice = true) { - // (收盘价 * 10) - 现金分红 * (1 - 税率) + (配股数量 * 配股价格) - // ------------------------------------------------------- - // (10 + 送股数量 + 配股数量) - var exDividendPrice = (closePrice * 10m - info.GiveCashAmount * (1m - dividendRate) + // 价格调整模式除权参考价 = + // 收盘价 * 10 - 【每股派息 * 10 * (1-分红税率)】 + 配股数 * 配股价 + // - ----------------------------------------------------- + // (10 + 送股数 + 配股数) * 拆股倍数 + // 场内链路默认继续把现金派息计入除权参考价; + // TRS Stock/Fund 现金模式显式关闭该项 :“【】” 号内数据。 + var cashPriceAdjustment = adjustCashDividendPrice + ? info.GiveCashAmount * (1m - dividendRate) + : 0m; + // 拆股倍数 + var splitFactor = GetSplitFactor(info); + // 除权参考价(TRS) : + // 收盘价 * 10 + 配股数 * 配股价 + // ------------------------------ + // (10 + 送股数 + 配股数) * 拆股倍数 + var exDividendPrice = ((closePrice * 10m - cashPriceAdjustment + info.RationedSharesAmount * info.RationedSharesPrice) - / (10m + info.GiveShareAmount + info.RationedSharesAmount); + / (10m + info.GiveShareAmount + info.RationedSharesAmount)) + / splitFactor; + // 除权系数 = 股权登记日收盘价 / 除权除息参考价 var priceRatio = exDividendPrice == 0 ? 0 : closePrice / exDividendPrice; - var shareFactor = 1m + info.GiveShareAmount / 10m; - return new CorporateActionFactors(priceRatio, shareFactor); + return new CorporateActionFactors(priceRatio); + } + + private static decimal GetSplitFactor(ex_dividend_info info) + { + if (info == null) + { + throw new ArgumentNullException(nameof(info)); + } + if (info.Split.HasValue && info.Split.Value <= 0m) + { + throw new ArgumentOutOfRangeException(nameof(info.Split), "拆/合股倍数必须大于 0"); + } + + // Split 为空表示未提供拆合股信息,按 1 兼容历史记录;例如 Split=0.1 时, + // 1000 份/100 元调整为 100 份/1000 元。0 或负数无法表达有效份额比例,直接拒绝。 + return info.Split ?? 1m; } /// @@ -798,10 +822,9 @@ namespace YLErp.Modules.TradeModule.DealModule } /** - * 在没有现金分红和配股时: - * 拆合股:10*closePrice / 10+GiveShareAmount - * 调整后数量 = 原数量 × 除权系数 - * 调整后价格 = 原价格 ÷ 除权系数 + * GiveShareAmount 表示每 10 份送股数量,Split 表示独立拆/合股倍数(空值按 1); + * 调整后数量 = 原数量 × (1 + GiveShareAmount / 10) × Split; + * 调整后价格 = 原价格 ÷ 上述数量系数(配股只参与非现金价格公式)。 */ private decimal GetRatioDecimal(ex_dividend_info info) { @@ -833,9 +856,12 @@ namespace YLErp.Modules.TradeModule.DealModule /// public double GetPositionAmount(double amount, ex_dividend_info info) { - // 数量只按送股/拆合股调整,现金分红和配股不增加持仓数量;10 送 10 时 - // 1000 份变为 2000 份,价格系数由 GetRatioDecimal 单独计算,不能在此重复套用。 - var result = (decimal)amount * (1m + info.GiveShareAmount / 10m); + // 这是旧场内/兼容链路的数量接口;TRS Stock/Fund 不走这里,而是在 + // SwapEodPositionService 中按 Excel公式 使用 PriceRatio。旧链路数量只按 + // 送股和独立拆合股调整,现金分红和配股不增加持仓数量。 + var result = (decimal)amount + * (1m + info.GiveShareAmount / 10m) + * GetSplitFactor(info); return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero); } @@ -900,6 +926,7 @@ namespace YLErp.Modules.TradeModule.DealModule : (DateTime?)null, GiveCashAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "派息金额"), out var value) ? value : 0, GiveShareAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "送股股数"), out value) ? value : 0, + Split = decimal.TryParse(getColValueFromTable(dt.Rows[i], "拆/合股倍数"), out var split) ? split : (decimal?)null, RationedSharesAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0, RationedSharesPrice = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股价"), out value) ? value : 0, OptId = OptUser.UserId, @@ -922,6 +949,10 @@ namespace YLErp.Modules.TradeModule.DealModule { throw new ServiceException($"第{i + 1}行真实除权日不正确"); } + if (info.Split.HasValue && info.Split.Value <= 0m) + { + throw new ServiceException($"第{i + 1}行拆/合股倍数必须大于0"); + } if (info.EffectiveDate.Value.Date < info.ExDividendDate.Value.Date) { throw new ServiceException($"第{i + 1}行真实除权日不应早于股权登记日"); @@ -985,6 +1016,11 @@ namespace YLErp.Modules.TradeModule.DealModule { target.RationedSharesPrice = source.RationedSharesPrice; } + if (source.Split.HasValue) + { + // Split 为空表示本次未提供,不能按历史兼容值 1 清空或覆盖旧倍数;明确提供 1 才覆盖。 + target.Split = source.Split.Value; + } if (source.EffectiveDate.HasValue) { // EffectiveDate 是日期语义,导入/接口可能带时分秒;统一只保留自然日。 @@ -1026,6 +1062,11 @@ namespace YLErp.Modules.TradeModule.DealModule errMsg = "股权登记日信息不存在"; return false; } + if (item.Split.HasValue && item.Split.Value <= 0m) + { + errMsg = "拆/合股倍数必须大于0"; + return false; + } // 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间 // 能命中同一个自然日业务键,也与数据库的一行模型保持一致。 @@ -1049,6 +1090,12 @@ namespace YLErp.Modules.TradeModule.DealModule item.RationedSharesAmount = OtcFormatHelper.FormatValue(item.RationedSharesAmount, 6); item.RationedSharesPrice = OtcFormatHelper.FormatValue(item.RationedSharesPrice, 6); item.GiveShareAmount = OtcFormatHelper.FormatValue(item.GiveShareAmount, 6); + if (item.Split.HasValue) + { + // 拆合股比例可能为 0.01、0.001 等小数,保留 12 位避免导入时 + // 被 6 位金额精度截断;日期字段则在上方统一归一化为自然日。 + item.Split = OtcFormatHelper.FormatValue(item.Split.Value, 12); + } // 先在当前批次内按业务键归并。第一条记录作为待保存目标,后续记录 // 只补充/覆盖非零字段,不会因为重复行而生成多条数据库记录。 @@ -1177,6 +1224,37 @@ namespace YLErp.Modules.TradeModule.DealModule /// public bool checkDividendInfoExecuteStatus(ex_dividend_info info) { + // TRS 公司行为以 EffectiveDate 为真正生效边界。登记日创建待生效事件不应锁定 + // 维护;只有交易已经完成 EffectiveDate(例如收盘到 7 月 30 日,而真实除权日为 + // 7 月 29 日)才禁止修改,避免修改后无法解释已落库的调整前后快照。 + if (info?.EffectiveDate.HasValue == true) + { + var effectiveDate = info.EffectiveDate.Value.Date; + var trsTradeIds = DbContext.trade + .Where(x => x.ValidState != ConsGlobal.InValid + && x.TradeType == "收益互换" + && x.UnderlyingCode == info.UnderlyingCode + && x.TradeDate <= effectiveDate + && x.ExerciseDate >= effectiveDate) + .Select(x => x.id) + .ToList(); + if (trsTradeIds.Count > 0) + { + // 是否仍被交易引用以当前有效 EOD 为准。公司行为事件本身是不可篡改 + // 历史,交易回退后仍会保留;若仅凭 Applied 事件锁定,回退到登记日前 + // 也无法纠错。生效日及以后还有有效 EOD 才表示当前仍已执行。 + var hasAppliedEod = DbContext.eod_swap_position.Any(x => + trsTradeIds.Contains(x.SwapTradeId) + && !x.Invalid + && x.UnderlyingCode == info.UnderlyingCode + && x.ValueDate >= effectiveDate); + if (hasAppliedEod) + { + return true; + } + } + } + var eodStatus = DbContext.eodStatus.Where(O => O.ValueDate == info.ExDividendDate && O.OptDate > info.OptDate).Any(); if (eodStatus) { diff --git a/YLErpWeb/App_Docs/导入模板/个股除权信息模板.xlsx b/YLErpWeb/App_Docs/导入模板/个股除权信息模板.xlsx index b5d20fdd892407d30e291202e5e01cf76589631f..d04a1e2222673db3609f3eb05e9b2afc3dd5afef 100644 GIT binary patch literal 7018 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 F@P829G&leN literal 11261 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 eZRCG3|1 - + 操作时间 操作人 操作内容 - 说明 + 说明(含公司行为前后要素) @@ -31,7 +31,7 @@ {{dateFormat(item.OptTime,'YYYY-MM-DD HH:mm:ss')}} {{item.OptName}} {{item.EventTypeName}} - {{item.EventReason}} + {{item.EventReason}} diff --git a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js index afee45e7..65c9cbb2 100644 --- a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js +++ b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js @@ -21,6 +21,7 @@ function saveInfo(dataId, rowId) { EffectiveDate: $("#" + rowId + "_EffectiveDate").val(), GiveCashAmount: $("#" + rowId + "_GiveCashAmount").val(), GiveShareAmount: $("#" + rowId + "_GiveShareAmount").val(), + Split: $("#" + rowId + "_Split").val(), ConversionShareAmount: $("#" + rowId + "_ConversionShareAmount").val(), RationedSharesAmount: $("#" + rowId + "_RationedSharesAmount").val(), RationedSharesPrice: $("#" + rowId + "_RationedSharesPrice").val() @@ -80,6 +81,7 @@ function gridComplete(obj) { EffectiveDate: null, GiveCashAmount: 0.0, GiveShareAmount: 0, + Split: 1, RationedSharesAmount: 0, RationedSharesPrice: 0, OptName: null, @@ -115,6 +117,8 @@ var colModelGrid = [{ name: 'GiveCashAmount', label: '派息金额(10股)', index: 'GiveCashAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true }, }, { name: 'GiveShareAmount', label: '送股股数(10股)', index: 'GiveShareAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true }, +}, { + name: 'Split', label: '拆/合股倍数', index: 'Split', width: 100, formatter: { number: { decimalPlaces: 6, defaultValue: '1' } }, editable: true, editrules: { number: true }, }, { name: 'RationedSharesAmount', label: '配股股数(10股)', index: 'RationedSharesAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true }, }, {