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 b5d20fdd..d04a1e22 100644 Binary files a/YLErpWeb/App_Docs/导入模板/个股除权信息模板.xlsx and b/YLErpWeb/App_Docs/导入模板/个股除权信息模板.xlsx differ diff --git a/YLErpWeb/Controllers/ex_dividend_infoController.cs b/YLErpWeb/Controllers/ex_dividend_infoController.cs index b5c7e0d4..e8db3bb8 100644 --- a/YLErpWeb/Controllers/ex_dividend_infoController.cs +++ b/YLErpWeb/Controllers/ex_dividend_infoController.cs @@ -52,6 +52,10 @@ namespace YLErp.Web.Controllers { throw new FormatException("标的代码、股权登记日或真实除权日信息不存在!"); } + if (info.Split.HasValue && info.Split.Value <= 0m) + { + throw new FormatException("拆/合股倍数必须大于0!"); + } if (QdpCalendarHelper.IsHoliday(info.ExDividendDate.Value)) { throw new FormatException("股权登记日不应为非交易日!"); diff --git a/YLErpWeb/Views/SwapTrade2/OperationHistory.cshtml b/YLErpWeb/Views/SwapTrade2/OperationHistory.cshtml index 70c6aa12..1cf2744f 100644 --- a/YLErpWeb/Views/SwapTrade2/OperationHistory.cshtml +++ b/YLErpWeb/Views/SwapTrade2/OperationHistory.cshtml @@ -16,14 +16,14 @@ - + 操作时间 操作人 操作内容 - 说明 + 说明(含公司行为前后要素) @@ -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 }, }, {