diff --git a/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs new file mode 100644 index 00000000..ed4f446b --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/FundCorporateActionRollbackAndUnwindTest.cs @@ -0,0 +1,326 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + [TestClass] + public class FundCorporateActionRollbackAndUnwindTest + { + private static readonly DateTime ExDate = new(2026, 8, 17); + + [TestMethod] + public void FCA_RB_001_回退选择最近实际Eod并遵守除权日边界() + { + var friday = CreateEod(new DateTime(2026, 8, 14), 1000m, 100m); + var exDate = CreateEod(ExDate, 2000m, 50m); + var invalidSunday = CreateEod(new DateTime(2026, 8, 16), 9999m, 1m); + invalidSunday.Invalid = true; + var snapshots = new[] { friday, invalidSunday, exDate }; + + var rollbackToExDate = SwapEodPositionService.SelectLatestEodPositionsBefore( + snapshots, + ExDate); + var rollbackAfterExDate = SwapEodPositionService.SelectLatestEodPositionsBefore( + snapshots, + ExDate.AddDays(1)); + + Assert.AreEqual(friday.ValueDate, rollbackToExDate.Single().ValueDate, + "回退到除权日应恢复除权前最近实际 EOD,不能用周日自然日或除权日自身"); + Assert.AreEqual(1000m, rollbackToExDate.Single().PosiQuantity); + Assert.AreEqual(exDate.ValueDate, rollbackAfterExDate.Single().ValueDate, + "回退到除权日之后应保留已经生效的除权 EOD"); + Assert.AreEqual(2000m, rollbackAfterExDate.Single().PosiQuantity); + } + + [TestMethod] + public void FCA_UW_001_最近FundEod恢复价格数量且重复恢复不重复除权() + { + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(ExDate, 2000m, 50m); + + Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(realtime, eod)); + Assert.AreEqual(2000m, realtime.PosiQuantity); + Assert.AreEqual(50m, realtime.PosiGrossPrice); + Assert.AreEqual(100000m, realtime.PosiNotionalValue); + + Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(realtime, eod)); + Assert.AreEqual(2000m, realtime.PosiQuantity, + "恢复 EOD 是复制快照,不是再次套 10 送 10 系数,不能变成 4000"); + Assert.AreEqual(50m, realtime.PosiGrossPrice, + "重复恢复不能把价格再次调整为 25"); + } + + [TestMethod] + public void FCA_UW_002_非Fund和最新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); + + var td = SwapDealTestFactory.CreateTrade(); + var realtime = CreateRealtimeFundPosition(); + realtime.PosiQuantity = 1500m; + realtime.PosiGrossPrice = 50m; + var service = CreateService(td, realtime, eod, hasCompletedFlow: true); + var unwindData = CreateFullCloseUnwindData(); + + Assert.IsFalse(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate.AddDays(1))); + Assert.AreEqual(1500m, realtime.PosiQuantity, + "EOD 后已有部分平仓流水时不能用 2000 份 EOD 覆盖实时剩余 1500 份"); + Assert.AreEqual(1000m, unwindData.CloseQty, + "未恢复基线时不得擅自改写前端请求,沿用既有当日实时流程"); + } + + [TestMethod] + public void FCA_UW_005_生效日盘中恢复前一Eod后再套除权() + { + var recordDate = new DateTime(2026, 8, 14); + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(recordDate, 1000m, 100m); + var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = ExDate, + GiveShareAmount = 10m, + ValidStatus = true + }); + var unwindData = CreateFullCloseUnwindData(); + + Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate)); + + Assert.AreEqual(2000m, realtime.PosiQuantity, + "8 月 17 日盘中应先从 8 月 14 日 EOD 恢复,再按 10 送 10 变为 2000 份"); + Assert.AreEqual(50m, realtime.PosiGrossPrice, + "真实除权生效日盘中应使用 50 元基准,不能继续使用登记日 100 元"); + Assert.AreEqual(2000m, unwindData.CloseQty); + Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice); + } + + [TestMethod] + public void FCA_UW_006_登记日盘中平仓不提前应用除权() + { + var recordDate = new DateTime(2026, 8, 14); + var realtime = CreateRealtimeFundPosition(); + // 8 月 14 日盘中尚未生成当日 EOD,最近可用快照应是 8 月 13 日。 + var eod = CreateEod(recordDate.AddDays(-1), 1000m, 100m); + var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = ExDate, + GiveShareAmount = 10m, + ValidStatus = true + }); + var unwindData = CreateFullCloseUnwindData(); + unwindData.ValueDate = recordDate; + unwindData.UnwindDate = recordDate.AddDays(1); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(1000m, unwindData.PositionQty, + "登记日仍使用除权前 EOD 基线,不能提前变为 2000 份"); + Assert.AreEqual(1000m, unwindData.CloseQty); + Assert.AreEqual(100m, unwindData.FlowEvents.Single().PosiGrossPrice, + "登记日盘中平仓价格仍应为 100 元,除权生效日才切换为 50 元"); + } + + [TestMethod] + public void FCA_UW_007_基金直接拆合股比例零点零一_平仓按新数量价格() + { + var recordDate = new DateTime(2026, 8, 14); + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(recordDate, 1000m, 100m); + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 100000d; + td.TradeAmount = 1000d; + var service = CreateService(td, realtime, eod, hasCompletedFlow: false); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = ExDate, + // 上游 splitratio=0.01 必须先转换为 10 * (0.01 - 1)=-9.9; + // 直接写 0.01 会按当前字段公式得到 1.001 倍,无法表达缩小为 0.01 倍。 + GiveShareAmount = -9.9m, + ValidStatus = true + }); + var unwindData = CreateFullCloseUnwindData(); + unwindData.ValueDate = ExDate; + unwindData.UnwindDate = ExDate.AddDays(1); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(10m, unwindData.PositionQty, + "Fund splitratio=0.01 时,有效平仓基线应为 1000 * 0.01 = 10 份"); + Assert.AreEqual(10m, unwindData.CloseQty); + Assert.AreEqual(10000m, unwindData.FlowEvents.Single().PosiGrossPrice, + "Fund 份额缩小为 0.01 倍时,直接平仓期初价应为 100 / 0.01 = 10000"); + } + + [TestMethod] + public void FCA_UW_003_正式平仓按FundEod基线重算PnL和现金() + { + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 100000d; + td.TradeAmount = 1000d; + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(ExDate, 2000m, 50m); + var service = CreateService(td, realtime, eod, hasCompletedFlow: false); + var unwindData = CreateFullCloseUnwindData(); + var floatEvent = unwindData.FlowEvents.Single(); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(2000m, unwindData.PositionQty); + Assert.AreEqual(2000m, unwindData.CloseQty); + Assert.AreEqual(100000m, unwindData.CloseNotionalValue); + Assert.AreEqual(50m, floatEvent.PosiGrossPrice); + Assert.AreEqual(20000m, floatEvent.MarkClosePnl, + "平仓价 60 - 除权后期初价 50,乘 2000 份,应为 20000"); + Assert.AreEqual(20000m, unwindData.SwapRealizedPnL); + Assert.AreEqual(-20000d, service.ClientCashCalls.Single().amount, 0.001d, + "客户现金必须使用后台按有效 EOD 重算后的平仓金额"); + } + + [TestMethod] + public void FCA_UW_004_现金分红后部分平仓从Eod名义本金扣减() + { + var td = SwapDealTestFactory.CreateTrade(); + td.StockEqvNotional = 100000d; + td.TradeAmount = 1000d; + var realtime = CreateRealtimeFundPosition(); + var eod = CreateEod(ExDate, 1000m, 99m); + var service = CreateService(td, realtime, eod, hasCompletedFlow: false); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: -500m, + closeMethod: (int)CloseMethodEnum.部分平仓, + closePercent: 0.5m, + closeQty: 500m, + closeNotionalValue: 50000m, + positionQty: 1000m); + unwindData.NotionalValue = 100000m; + unwindData.PosiNotionalValue = 100000m; + unwindData.FlowEvents.Add(new swap_flow_event + { + PositionId = 101, + EventType = (int)SwapEventTypeEnum.平仓, + UnderlyingCode = "FUND.TEST", + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + PositionType = (int)PositionTypeFlag.Long, + PayDirection = 1, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + TradingAmountAvg = 99m, + Quantity = 500m, + PositionQty = 500m, + ContractSize = 1m, + MarkClosePnl = -500m + }); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(99000m, unwindData.PosiNotionalValue); + Assert.AreEqual(49500m, unwindData.CloseNotionalValue); + Assert.AreEqual(0m, unwindData.SwapRealizedPnL, + "市场价和除权后期初价同为 99 时不应产生额外盯市损益"); + Assert.AreEqual(49500d, td.StockEqvNotional, 0.001d, + "应从 EOD 有效名义本金 99000 扣除 49500,不能从旧 trade 值 100000 扣减"); + Assert.AreEqual(500d, td.TradeAmount, 0.001d); + } + + private static TestableSwapDealService CreateService( + trade td, + swap_position realtime, + eod_swap_position eod, + bool hasCompletedFlow) + { + return new TestableSwapDealService(td) + { + RealtimeFloatPosition = realtime, + LatestFundEodPosition = eod, + HasCompletedFlowAfterLatestFundEod = hasCompletedFlow, + ActiveSwapPositions = new List { realtime } + }; + } + + private static swap_position CreateRealtimeFundPosition() + { + return new swap_position + { + SwapTradeId = SwapDealTestFactory.SwapTradeId, + PositionId = 101, + IsInitial = false, + PosiDirection = 1, + PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "FUND.TEST", + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + PosiQuantity = 1000m, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + PosiNetFeePrice = 100m, + PosiNetNoFeePrice = 100m, + PosiNotionalValue = 100000m, + ContractSize = 1m + }; + } + + private static eod_swap_position CreateEod(DateTime valueDate, decimal quantity, decimal price) + { + return new eod_swap_position + { + SwapTradeId = SwapDealTestFactory.SwapTradeId, + PositionId = 101, + ValueDate = valueDate, + PosiDirection = 1, + PositionType = (int)PositionTypeFlag.Long, + UnderlyingCode = "FUND.TEST", + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + PosiQuantity = quantity, + PosiGrossPrice = price, + PosiNetPrice = price, + PosiNetFeePrice = price, + PosiNetNoFeePrice = price, + UnderlyingPrice = price, + PosiNotionalValue = quantity * price, + ContractSize = 1m + }; + } + + private static UnwindData CreateFullCloseUnwindData() + { + var data = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: -40000m, + closeMethod: (int)CloseMethodEnum.全部平仓, + closePercent: 1m, + closeQty: 1000m, + closeNotionalValue: 100000m, + positionQty: 1000m); + data.NotionalValue = 100000m; + data.PosiNotionalValue = 100000m; + data.FlowEvents.Add(new swap_flow_event + { + PositionId = 101, + EventType = (int)SwapEventTypeEnum.平仓, + UnderlyingCode = "FUND.TEST", + UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund, + PositionType = (int)PositionTypeFlag.Long, + PayDirection = 1, + PosiGrossPrice = 100m, + PosiNetPrice = 100m, + TradingAmountAvg = 60m, + Quantity = 1000m, + PositionQty = 0m, + ContractSize = 1m, + MarkClosePnl = -40000m + }); + return data; + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs index 3749384a..8f3b723f 100644 --- a/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapPositionComposeScenarioTest.cs @@ -57,6 +57,7 @@ namespace YLErp.Modules.SwapModule protected override List FindEodSwapsByDate(DateTime valueDate) => _eodSwaps; protected override List FindFlowEvents(int swapTradeId, DateTime settleDate) => _flowEvents; protected override List FindCompletedFlowEvents(List tradeIds) => _flowEvents; + public override DateTime? GetPreDealDate(int tradeId, DateTime valueDate, List eventTypes) => null; protected override List FindEodSwapPositions(int swapTradeId, DateTime preSettleDate) => _eodPositions.Where(x => x.SwapTradeId == swapTradeId && x.ValueDate >= preSettleDate).ToList(); protected override List FindSwapPositions(int swapTradeId) @@ -93,6 +94,20 @@ namespace YLErp.Modules.SwapModule public void ExecuteSwapPositionCompose(DateTime settleDate, DateTime preSettleDate) => SwapPositionCompose(settleDate, preSettleDate, null); + + public void ExecuteFundCorporateActions( + IReadOnlyCollection positions, + IReadOnlyCollection previousEodPositions, + IReadOnlyCollection flowEvents, + IReadOnlyCollection dividendInfos) + { + ApplyFundCorporateActions( + positions, + previousEodPositions, + flowEvents, + dividendInfos.ToDictionary(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase), + SettleDate); + } } #endregion @@ -144,7 +159,7 @@ namespace YLErp.Modules.SwapModule PosiDirection = 1, PositionType = (int)PositionTypeFlag.Long, Invalid = false, PosiQuantity = qty, PosiGrossPrice = grossPrice, PosiNetPrice = 1.0050m, PosiNetFeePrice = 1.0030m, PosiNetNoFeePrice = 1.0000m, - UnderlyingCode = "220205.IB", ContractSize = 1m, + UnderlyingCode = "220205.IB", UnderlyingPrice = grossPrice, ContractSize = 1m, InterestIncomeSum = 0m, InterestProfitSum = 0m, PosiNotionalValue = qty }; } @@ -161,6 +176,41 @@ namespace YLErp.Modules.SwapModule }; } + private static ex_dividend_info CreateFundCorporateAction( + decimal cashAmount = 0m, + decimal shareAmount = 0m) + { + return new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = SettleDate, + EffectiveDate = SettleDate, + GiveCashAmount = cashAmount, + GiveShareAmount = shareAmount, + ValidStatus = true + }; + } + + private static void SetFundLeg(swap_position position, eod_swap_position previousEod) + { + position.UnderlyingCode = "FUND.TEST"; + position.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund; + position.PosiGrossPrice = 100m; + position.PosiNetPrice = 102m; + position.PosiNetFeePrice = 104m; + position.PosiNetNoFeePrice = 106m; + + previousEod.UnderlyingCode = position.UnderlyingCode; + previousEod.UnderlyingInstrumentType = position.UnderlyingInstrumentType; + previousEod.PosiGrossPrice = position.PosiGrossPrice; + previousEod.PosiNetPrice = position.PosiNetPrice; + previousEod.PosiNetFeePrice = position.PosiNetFeePrice; + previousEod.PosiNetNoFeePrice = position.PosiNetNoFeePrice; + previousEod.PosiNotionalValue = previousEod.PosiGrossPrice + * previousEod.PosiQuantity + * previousEod.ContractSize; + } + #endregion // ================================================================ @@ -238,6 +288,282 @@ namespace YLErp.Modules.SwapModule Console.WriteLine($"SPC_003: PosiQuantity={floatEod.PosiQuantity}, TdCloseQty={floatEod.TdCloseQty} ✅"); } + [TestMethod] + public void SPC_FUND_001_送股除权_调整价格数量并重算持仓结果() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); + var actual = previousEod.Clone(); + actual.ValueDate = SettleDate; + actual.UnderlyingPrice = 100m; + + service.ExecuteFundCorporateActions( + new[] { actual }, + new[] { previousEod }, + Array.Empty(), + service.ExDividendInfos); + + Assert.AreEqual(2000m, actual.PosiQuantity); + Assert.AreEqual(1000m, actual.TdChangedQty); + Assert.AreEqual(50m, actual.PosiGrossPrice); + Assert.AreEqual(51m, actual.PosiNetPrice); + Assert.AreEqual(52m, actual.PosiNetFeePrice); + Assert.AreEqual(53m, actual.PosiNetNoFeePrice); + Assert.AreEqual(100000m, actual.PosiNotionalValue); + Assert.AreEqual(200000m, actual.UnderlyingMarketValue); + Assert.AreEqual(100000m, actual.PosiMtmPnL); + Assert.AreEqual(100000m, actual.PosiProfitSum); + } + + [TestMethod] + public void SPC_FUND_002_现金分红_只调整价格不生成分红流水() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(CreateFundCorporateAction(cashAmount: 10m)); + var actual = previousEod.Clone(); + actual.ValueDate = SettleDate; + actual.UnderlyingPrice = 100m; + + service.ExecuteFundCorporateActions( + new[] { actual }, + new[] { previousEod }, + Array.Empty(), + service.ExDividendInfos); + + Assert.AreEqual(1000m, actual.PosiQuantity); + Assert.AreEqual(0m, actual.TdChangedQty); + Assert.AreEqual(99m, actual.PosiGrossPrice); + Assert.AreEqual(0m, actual.TdPosiDividend); + Assert.AreEqual(0m, actual.PosiDividendSum); + Assert.AreEqual(99000m, actual.PosiNotionalValue); + } + + [TestMethod] + public void SPC_FUND_003_同日重跑_从前日基线重算不重复除权() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); + var todayEod = previousEod.Clone(); + todayEod.ValueDate = SettleDate; + todayEod.UnderlyingPrice = 100m; + + service.ExecuteFundCorporateActions( + new[] { todayEod }, + new[] { previousEod }, + Array.Empty(), + service.ExDividendInfos); + service.ExecuteFundCorporateActions( + new[] { todayEod }, + new[] { previousEod }, + Array.Empty(), + service.ExDividendInfos); + + Assert.AreEqual(2000m, todayEod.PosiQuantity); + Assert.AreEqual(1000m, todayEod.TdChangedQty); + Assert.AreEqual(50m, todayEod.PosiGrossPrice); + Assert.AreEqual(100000m, todayEod.PosiNotionalValue); + } + + [TestMethod] + public void SPC_FUND_004_非Fund标的_即使命中公司行为也不调整() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + position.UnderlyingCode = "FUND.TEST"; + position.PosiGrossPrice = 100m; + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + previousEod.UnderlyingCode = position.UnderlyingCode; + previousEod.UnderlyingInstrumentType = "TBonds"; + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 10m)); + var actual = previousEod.Clone(); + actual.ValueDate = SettleDate; + actual.UnderlyingPrice = 100m; + + service.ExecuteFundCorporateActions( + new[] { actual }, + new[] { previousEod }, + Array.Empty(), + service.ExDividendInfos); + + Assert.AreEqual(1000m, actual.PosiQuantity); + Assert.AreEqual(100m, actual.PosiGrossPrice); + Assert.AreEqual(0m, actual.TdChangedQty); + } + + [TestMethod] + public void SPC_FUND_005_同日同代码多条有效记录_明确失败() + { + var service = new TestableSwapEodService( + new List { CreateTrade() }, + new List(), + new List(), + new List(), + new List { CreateExtend() }, + new List()); + service.ExDividendInfos.Add(CreateFundCorporateAction(cashAmount: 1m)); + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: 1m)); + + var exception = Assert.ThrowsException(() => + service.ExecuteSwapPositionCompose(SettleDate, PreSettleDate)); + + StringAssert.Contains(exception.Message, "存在多条有效除权记录"); + } + + [TestMethod] + public void SPC_FUND_006_登记日Eod保持除权前数量价格_生效日才调整() + { + var recordDate = SettleDate; + var effectiveDate = recordDate.AddDays(3); + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = effectiveDate, + GiveShareAmount = 10m, + ValidStatus = true + }); + + service.ExecuteSwapPositionCompose(recordDate, PreSettleDate); + + var recordEod = service.CreatedEodPositions.First(x => x.PositionId == 1); + Assert.AreEqual(1000m, recordEod.PosiQuantity, + "登记日 EOD 仍展示除权前数量,不能提前变成 2000"); + Assert.AreEqual(100m, recordEod.PosiGrossPrice, + "登记日 EOD 仍展示除权前价格,不能提前变成 50"); + } + + [TestMethod] + public void SPC_FUND_007_生效日先以除权后基线处理平仓_1000平300得到1700份50元() + { + var recordDate = SettleDate; + var effectiveDate = recordDate.AddDays(3); + var td = CreateTrade(); + var initialPosition = CreateFloatPosition(1, 1000m); + var realtimePosition = initialPosition.Clone(); + realtimePosition.id = 2; + realtimePosition.IsInitial = false; + realtimePosition.PositionId = initialPosition.id; + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + previousEod.ValueDate = recordDate; + SetFundLeg(initialPosition, previousEod); + SetFundLeg(realtimePosition, previousEod); + var closeFlow = CreateCloseFlowEvent(initialPosition.id, 300m); + closeFlow.UnderlyingCode = "FUND.TEST"; + closeFlow.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Fund; + closeFlow.DividendIn = 0m; + var service = new TestableSwapEodService( + new List { td }, + new List { initialPosition, realtimePosition }, + new List { previousEod }, + new List { new eod_swap { SwapTradeId = SwapTradeId, ValueDate = recordDate } }, + new List { CreateExtend() }, + new List { closeFlow }, + price: 100m); + service.ExDividendInfos.Add(new ex_dividend_info + { + UnderlyingCode = "FUND.TEST", + ExDividendDate = recordDate, + EffectiveDate = effectiveDate, + GiveShareAmount = 10m, + ValidStatus = true + }); + + service.ExecuteSwapPositionCompose(effectiveDate, recordDate); + + var effectiveEod = service.CreatedEodPositions.First(x => x.PositionId == 1); + Assert.AreEqual(1700m, effectiveEod.PosiQuantity, + "生效日先把 1000 份变为 2000 份,再平仓 300 份,应剩 1700 而非 1400"); + Assert.AreEqual(50m, effectiveEod.PosiGrossPrice, + "10 送 10 后期初价格应为 50"); + } + + [TestMethod] + public void SPC_FUND_008_上游splitratio零点零一映射GiveShareAmount负九点九_Eod数量价格调整() + { + var td = CreateTrade(); + var position = CreateFloatPosition(1, 1000m); + var previousEod = CreateFloatEodPosition(1, 1000m, 100m); + SetFundLeg(position, previousEod); + var service = new TestableSwapEodService( + new List { td }, + new List { position }, + new List { previousEod }, + new List(), + new List { CreateExtend() }, + new List(), + price: 100m); + // 上游 splitratio=sharesafter/sharesbefore=0.01,落库前按 + // GiveShareAmount=10*(splitratio-1) 转换为 -9.9;现有公式因此得到 0.01 倍。 + service.ExDividendInfos.Add(CreateFundCorporateAction(shareAmount: -9.9m)); + var actual = previousEod.Clone(); + actual.ValueDate = SettleDate; + actual.UnderlyingPrice = 100m; + + service.ExecuteFundCorporateActions( + new[] { actual }, + new[] { previousEod }, + Array.Empty(), + service.ExDividendInfos); + + Assert.AreEqual(10m, actual.PosiQuantity, + "上游 splitratio=0.01 映射为 GiveShareAmount=-9.9,1000 份应调整为 10 份"); + Assert.AreEqual(10000m, actual.PosiGrossPrice, + "上游 splitratio=0.01 映射为 GiveShareAmount=-9.9,期初价格应反向放大 100 倍"); + } + // ================================================================ // 场景4:未收盘抛异常 // ================================================================ diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs index 8b404f7e..6663355a 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs @@ -24,6 +24,13 @@ namespace YLErp.Modules.SwapModule public int SaveAllChangesCount; public int CloseReCheckCallCount; + /// Fund 盤中基线测试输入;生产服务通过数据库查询同名 seam。 + public swap_position RealtimeFloatPosition { get; set; } + public eod_swap_position LatestFundEodPosition { get; set; } + public bool HasCompletedFlowAfterLatestFundEod { get; set; } + public List ActiveSwapPositions { get; set; } = new(); + public List ExDividendInfos { get; } = new(); + public TestableSwapDealService(trade td, Dictionary swapEvents = null, Dictionary> flowEventsByEventId = null) @@ -36,6 +43,33 @@ namespace YLErp.Modules.SwapModule protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null; + protected override List FindActiveSwapPositions(int tradeId) + => ActiveSwapPositions; + + protected override swap_position FindRealtimeFloatPosition(UnwindData unwindData) + => RealtimeFloatPosition; + + protected override eod_swap_position FindLatestFundEodPosition(int tradeId, long positionId, DateTime valueDate) + => LatestFundEodPosition; + + protected override bool HasCompletedFlowAfterFundEod(int tradeId, long positionId, DateTime eodDate, DateTime valueDate) + => HasCompletedFlowAfterLatestFundEod; + + protected override ex_dividend_info FindFundCorporateAction(string underlyingCode, DateTime valueDate) + => ExDividendInfos.FirstOrDefault(x => x.ValidStatus + && x.UnderlyingCode == underlyingCode + && x.EffectiveDate == valueDate.Date); + + protected override decimal GetFundCorporateActionClosePrice( + ex_dividend_info dividendInfo, + decimal fallbackPrice) + => fallbackPrice; + + protected override decimal GetFundDividendTaxRate() => 0m; + + public bool RestoreEffectiveFundPositionForTest(UnwindData unwindData, DateTime valueDate) + => TryRestoreEffectiveFundPosition(unwindData, valueDate); + protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) { ClientCashCalls.Add((amount, action, valueDate)); diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs index 6539abe3..f034d731 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs @@ -42,6 +42,9 @@ namespace YLErp.Modules.SwapModule /// AddClientCash 调用记录(金额, 操作) public List<(double amount, string action)> ClientCashCalls { get; } = new(); + /// SwapPositionCompose 使用的公司行为内存数据;默认空,避免测试访问数据库。 + public List ExDividendInfos { get; } = new(); + /// 自增 id 模拟器(新增 eod 时分配 id) private int _nextId = 1; @@ -78,6 +81,25 @@ namespace YLErp.Modules.SwapModule return 1.0; // 本币,汇率=1 } + protected override List FindExDividendInfos(DateTime settleDate) + { + return ExDividendInfos + .Where(x => x.ValidStatus + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value.Date == settleDate.Date) + .ToList(); + } + + protected override decimal GetFundCorporateActionClosePrice( + ex_dividend_info dividendInfo, + decimal fallbackPrice) + => fallbackPrice; + + protected override decimal GetDividendTaxRate() + { + return 0m; + } + protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) { ClientCashCalls.Add((amount, action)); diff --git a/YLErpDAL/Model/ExDividendInfo.cs b/YLErpDAL/Model/ExDividendInfo.cs index bbf76a41..aa4b4c14 100644 --- a/YLErpDAL/Model/ExDividendInfo.cs +++ b/YLErpDAL/Model/ExDividendInfo.cs @@ -22,6 +22,14 @@ namespace YLErp.DBModels [DisplayName("股权登记日")] public DateTime? ExDividendDate { get; set; } + /// + /// 真实除权生效日。 + /// ExDividendDate 表示登记日,EffectiveDate 表示从哪个 EOD 起数量/价格基线 + /// 才允许切换到除权后口径;两者可能因周末、节假日或公告安排而不同。 + /// + [DisplayName("真实除权日")] + public DateTime? EffectiveDate { get; set; } + /// /// 税率 /// @@ -96,5 +104,10 @@ namespace YLErp.DBModels /// 股权登记日 /// public DateTime? ExDividendDate { get; set; } + + /// + /// 真实除权生效日。 + /// + public DateTime? EffectiveDate { get; set; } } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 00f3419d..4a376890 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -243,6 +243,246 @@ namespace YLErp.Modules.SwapModule return td.ExerciseDate.Value.AddDays(-1); } + /// 查询交易当前有效的初始腿和实时腿。测试可返回内存快照,避免初始化测试触库。 + protected virtual List FindActiveSwapPositions(int tradeId) + { + return DbContext.swap_position + .Where(x => x.SwapTradeId == tradeId && !x.Invalid) + .ToList(); + } + + /// + /// 找到平仓数据对应的实时浮动腿。正式路径以 PositionId 绑定,缺失时才按标的代码兜底; + /// 这样后台不会把前端传入的价格当成权威基线。测试可 override 为内存持仓。 + /// + protected virtual swap_position FindRealtimeFloatPosition(UnwindData unwindData) + { + if (unwindData == null) + { + return null; + } + + var floatEvent = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); + var query = DbContext.swap_position + .Where(x => x.SwapTradeId == unwindData.SwapTradeId && !x.IsInitial && !x.Invalid + && !string.IsNullOrEmpty(x.UnderlyingCode)); + if (floatEvent?.PositionId > 0) + { + var byPositionId = query.FirstOrDefault(x => x.PositionId == floatEvent.PositionId); + if (byPositionId != null) + { + return byPositionId; + } + } + if (!string.IsNullOrEmpty(floatEvent?.UnderlyingCode)) + { + var byCode = query.FirstOrDefault(x => x.UnderlyingCode == floatEvent.UnderlyingCode); + if (byCode != null) + { + return byCode; + } + } + return query.FirstOrDefault(); + } + + /// 查询 valueDate 当日已经生效的最近有效 Fund EOD。 + protected virtual eod_swap_position FindLatestFundEodPosition( + int tradeId, + long positionId, + DateTime valueDate) + { + return new SwapEodPositionService(this) + .GetLatestValidEodPosition(tradeId, positionId, valueDate); + } + + /// + /// 查询 valueDate 当天真正生效的 Fund 公司行为。 + /// ExDividendDate 只是登记日,盘中基线不能按登记日提前切换;只有 + /// EffectiveDate == valueDate 时才把上一 EOD 的 Q/P 转成当日 BOD 的除权后 Q/P。 + /// + protected virtual ex_dividend_info FindFundCorporateAction( + string underlyingCode, + DateTime valueDate) + { + return DbContext.ex_dividend_info.FirstOrDefault(x => x.ValidStatus + && x.UnderlyingCode == underlyingCode + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value == valueDate.Date); + } + + /// + /// 公司行为系数仍使用登记日收盘价,而不是生效日盘中/收盘价。 + /// 测试可用 EOD 快照价格作为回退值;生产从登记日行情表取真实收盘价。 + /// + protected virtual decimal GetFundCorporateActionClosePrice( + ex_dividend_info dividendInfo, + decimal fallbackPrice) + { + if (!dividendInfo.ExDividendDate.HasValue) + { + return fallbackPrice; + } + + var closePrice = new EodPriceProvider(dividendInfo.ExDividendDate.Value) + .GetPrice(dividendInfo.UnderlyingCode, SettlementTypeEnum.ClosePrice); + return Convert.ToDecimal(closePrice); + } + + /// 读取 Fund 现金分红税率;单元测试可固定为 0,避免依赖系统日期配置。 + protected virtual decimal GetFundDividendTaxRate() + => new DividendService(this).GetDividendTaxRateDecimal(); + + /// + /// 判断最新 EOD 之后是否已有同一浮动腿的完成流水。若有,说明当日实时持仓已发生部分平仓/互换, + /// 不能再把较早 EOD 的数量覆盖回来,否则会抹掉当日成交结果。 + /// + protected virtual bool HasCompletedFlowAfterFundEod( + int tradeId, + long positionId, + DateTime eodDate, + DateTime valueDate) + { + var asOfDate = valueDate.Date; + return DbContext.swap_flow_event.Any(x => x.SwapTradeId == tradeId + && x.PositionId == positionId + && x.DataState == (int)SwapFlowDateStateEnum.完成 + && x.EventDate > eodDate + && x.EventDate <= asOfDate); + } + + /// + /// 恢复实时 Fund 浮动腿到截至指定日有效的 EOD 基线。 + /// 这是唯一允许把 EOD 公司行为结果带入盘中平仓的入口:10 送 10 后 EOD 是 2000 份/50 + /// 时,下一日直接使用 2000/50,不再把前端可能传入的 1000/100 或已除权价格重复套系数。 + /// 若最新 EOD 后存在完成流水则保持实时腿原值,避免覆盖当日部分平仓;非 Fund、无 EOD + /// 和固定/利息腿均返回 false,沿用原逻辑。 + /// + protected virtual bool TryRestoreEffectiveFundPosition( + swap_position position, + DateTime valueDate) + { + // 只对收取方向的 Fund 浮动腿恢复 EOD;固定腿、利息腿和支付方向不应被公司行为改写。 + // 无历史 EOD 或最新 EOD 后已有完成流水时返回 false,由调用方保持实时持仓原值, + // 不伪造一份快照,也不把较早的 2000 份/50 覆盖掉当日已经部分平仓后的实时数量。 + if (position == null + || position.PosiDirection <= 0 + || position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund) + { + return false; + } + + var eodPosition = FindLatestFundEodPosition( + position.SwapTradeId, + position.PositionId, + valueDate); + if (eodPosition == null + || HasCompletedFlowAfterFundEod( + position.SwapTradeId, + position.PositionId, + eodPosition.ValueDate, + valueDate)) + { + return false; + } + + if (!SwapEodPositionService.RestoreFundPositionFromEod(position, eodPosition)) + { + return false; + } + + // 最近 EOD 已经处于生效日或更晚时,说明该快照本身已经是除权后基线, + // 不能再次套系数。只有“最近 EOD < EffectiveDate <= valueDate”时, + // 才在盘中恢复后补一次公司行为。 + var corporateAction = FindFundCorporateAction( + position.UnderlyingCode, + valueDate); + if (corporateAction?.EffectiveDate > eodPosition.ValueDate.Date + && corporateAction.EffectiveDate.Value.Date <= valueDate.Date) + { + var closePrice = GetFundCorporateActionClosePrice( + corporateAction, + position.PosiGrossPrice); + if (closePrice <= 0) + { + throw new ServiceException( + $"Fund 标的【{position.UnderlyingCode}】登记日【{corporateAction.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); + } + + var dividendTaxRate = GetFundDividendTaxRate(); + SwapEodPositionService.ApplyFundCorporateActionToPosition( + position, + corporateAction, + closePrice, + dividendTaxRate); + } + + return true; + } + + /// + /// 在直接提交前复核前端平仓数据。基线恢复成功时同步浮动流水价格、有效数量和名义本金, + /// 并拒绝 CloseQty 超过有效 EOD 数量;全平请求则把数量规范为当前有效全部持仓。 + /// + protected virtual bool TryRestoreEffectiveFundPosition( + UnwindData unwindData, + DateTime valueDate) + { + var position = FindRealtimeFloatPosition(unwindData); + if (!TryRestoreEffectiveFundPosition(position, valueDate)) + { + return false; + } + + var floatEvent = unwindData.FlowEvents?.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode)); + var effectiveQty = position.PosiQuantity; + var requestedQty = unwindData.CloseQty; + var fullClose = unwindData.CloseMethod == (int)CloseMethodEnum.全部平仓 + || unwindData.ClosePercent >= 1m; + // CloseQty 是部分平仓请求的数量口径;全平请求忽略前端缓存的旧数量,统一取 EOD 有效数量。 + // 例如 10 送 10 后 EOD 为 2000 份/50,前端仍传 1000 份时,全平必须落成 2000 份, + // 否则会遗留 1000 份;现金派现后若 EOD 名义本金为 99000,平一半应按 49500 扣减。 + // 若交易级余额仍沿用旧值 100000,再扣有效平仓额 49500,就会错误留下 50500。 + if (requestedQty < 0m || (!fullClose && requestedQty > effectiveQty)) + { + throw new ServiceException( + $"Fund 浮动腿平仓数量 {requestedQty} 超过截至 {valueDate:yyyy-MM-dd} 有效持仓 {effectiveQty}"); + } + + var closeQty = fullClose ? effectiveQty : requestedQty; + var closeNotional = fullClose + ? position.PosiNotionalValue + : Math.Round( + closeQty * position.PosiGrossPrice * position.ContractSize, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + unwindData.PositionQty = effectiveQty; + unwindData.PosiNotionalValue = position.PosiNotionalValue; + unwindData.CloseQty = closeQty; + unwindData.CloseNotionalValue = closeNotional; + if (!fullClose) + { + unwindData.ClosePercent = unwindData.NotionalValue > 0m + ? closeNotional / unwindData.NotionalValue + : (effectiveQty == 0m ? 0m : closeQty / effectiveQty); + } + + if (floatEvent != null) + { + floatEvent.PosiGrossPrice = position.PosiGrossPrice; + floatEvent.PosiNetPrice = position.PosiNetPrice; + floatEvent.TradingAmountNetAvg = position.PosiNetNoFeePrice; + floatEvent.TradingAmountNetFeeAvg = position.PosiNetFeePrice; + floatEvent.Quantity = closeQty; + floatEvent.PositionQty = effectiveQty - closeQty; + floatEvent.ContractSize = position.ContractSize; + + // EOD 恢复会改变入场基准和有效平仓数量;按当前平仓价重算前端派生盈亏。 + // FloatPnlSum 是只读属性,由 MarkClosePnl、费用和分红自动派生,不能直接写入。 + RecalculateNormalizedUnwindAmounts(unwindData); + } + return true; + } + #endregion public SwapDealService(OptUserInfo optUser) : base(optUser) @@ -300,7 +540,7 @@ namespace YLErp.Modules.SwapModule public UnwindData InitUnwind(int tradeId) { var td = DbContext.trade.Find(tradeId); - var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid); + var positions = FindActiveSwapPositions(tradeId); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode); bool commodity = ConsGlobal.InstrumentType.CalcTypeIsFutures(um.UnderlyingInstrumentType); List eventTyps = new List() { (int)SwapEventTypeEnum.自动互换, (int)SwapEventTypeEnum.互换 }; @@ -310,6 +550,11 @@ namespace YLErp.Modules.SwapModule td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); var oriPosition = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && x.IsInitial).FirstOrDefault(); + // Fund 的盘中平仓基线来自最近有效 EOD;10 送 10 后应直接使用 2000 份/50, + // 不能继续读取实时表中的 1000 份/100 再让前端重复套用除权系数。 + var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate); + // 恢复失败表示非 Fund、无历史 EOD,或 EOD 后已有完成流水;此时保留当前实时值, + // 继续原有盘中流程,避免用不完整快照制造数量/价格。 var preDealDate = GetPreDealDate(tradeId, dealDate, eventTyps); var hasProcess = HasTradeProcess(); swap_flow_event floatEvent = new swap_flow_event(); @@ -344,7 +589,11 @@ namespace YLErp.Modules.SwapModule unwindData.StructureType = td.StructureType; unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); - unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); + // 现金分红会调整 EOD 期初价但不改数量,因此持仓名义本金可能从 100000 变为 99000。 + // 只有 Fund EOD 基线恢复成功时才使用该值;其他品种继续沿用 trade 原口径。 + unwindData.PosiNotionalValue = restoredFundBaseline + ? position.PosiNotionalValue + : Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; unwindData.CloseMethod = (int)CloseMethodEnum.全部平仓; @@ -525,7 +774,7 @@ namespace YLErp.Modules.SwapModule { var checkEventTypes = new List() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; var td = DbContext.trade.Find(tradeId); - var positions = DbContext.swap_position.Where(x => x.SwapTradeId == tradeId && !x.Invalid); + var positions = FindActiveSwapPositions(tradeId); var um = DataCacheProvider.GetUnderlyingDataSource().GetData(td.UnderlyingCode); List eventTypes = new List() { (int)SwapFlowEventTypeEnum.互换, (int)SwapFlowEventTypeEnum.自动互换 }; var maxIncomeValueDate = GetMaxIncomeValueDate(td); @@ -534,6 +783,10 @@ namespace YLErp.Modules.SwapModule var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == tradeId); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); + // 收益结算与手工平仓共用 Fund 的有效 EOD 基线,避免仍返回除权前价格/数量。 + var restoredFundBaseline = TryRestoreEffectiveFundPosition(position, dealDate); + // 若无法恢复(例如当日已有互换/平仓流水),这里故意沿用实时腿,不能把较早 EOD + // 当作当日最终状态;收益结算的其余字段仍按原始实时口径组装。 //var preSettleDate = CheckLastEod(dealDate, td.StartDate.Value, tradeId);//上一交易日期 var preDealDate = GetPreDealDate(tradeId, dealDate, eventTypes); var hasProcess = HasTradeProcess(); @@ -570,7 +823,9 @@ namespace YLErp.Modules.SwapModule unwindData.StructureType = td.StructureType; unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); - unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); + unwindData.PosiNotionalValue = restoredFundBaseline + ? position.PosiNotionalValue + : Convert.ToDecimal(td.StockEqvNotional); unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; unwindData.ClosePercent = unwindData.PosiNotionalValue / unwindData.NotionalValue; @@ -1574,6 +1829,18 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } NormalizeEventUnwindDate(unwindData); + // 提交时再次从有效 EOD/实时腿复核基线,不能只相信前端缓存的数量和价格。 + var restoredFundBaseline = TryRestoreEffectiveFundPosition(unwindData, unwindData.ValueDate); + // 这是直接提交路径的最后一道复核。若返回 false(非 Fund、无快照、或 EOD 后已有完成流水), + // 不改写前端数据,沿用当日实时持仓;审批冻结事件和自动平仓入口不经过此复核,见下方说明。 + if (restoredFundBaseline) + { + // 正式提交必须让交易级余额与同一 Fund EOD 基线一致,再执行原有扣减。 + // 例:派现后有效名义本金为 99000,平掉一半 49500 后应剩 49500; + // 若仍从 trade 旧值 100000 扣减,会错误留下 50500。 + td.StockEqvNotional = Convert.ToDouble(unwindData.PosiNotionalValue); + td.TradeAmount = Convert.ToDouble(unwindData.PositionQty); + } NormalizeNotionalValues(unwindData); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.平仓, "系统操作_平仓"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 @@ -1635,6 +1902,8 @@ namespace YLErp.Modules.SwapModule var tradeExtend = DbContext.trade_extend.FirstOrDefault(x => x.TradeId == td.id); td.trade_extend = tradeExtend; var position = positions.Where(x => !string.IsNullOrEmpty(x.UnderlyingCode) && !x.IsInitial).FirstOrDefault(); + // 自动平仓由系统流水直接生成,当前入口沿用实时持仓和传入平仓数量,未重新读取 Fund EOD。 + // 因此它不具备手工 SwapUnwind 的 EOD 复核保护,生产上需确保自动流水已在正确的 EOD 基线之后生成。 var storagePriceRound = ConsGlobal.InstrumentType.IsBond(position?.UnderlyingInstrumentType) ? ConsGlobal.PriceRound : ConsGlobal.SwapDeliveryPriceRound; @@ -2047,6 +2316,9 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } NormalizeEventUnwindDate(unwindData); + // 正常页面先由 InitIncome 读取最近有效 Fund EOD;本提交方法本身不再重读快照, + // 直接使用调用方传入的数据。若数据来自待复核事件,则它是申请时冻结的快照,日期之后的除权 + // 不会在这里回写,属于审批链路的残余风险。 ValidateIncomeValueDate(unwindData, td); NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum.互换, "系统操作_互换"); //CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制 @@ -2083,6 +2355,9 @@ namespace YLErp.Modules.SwapModule { throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效"); } + // 审批通过消费申请时序列化的 unwindData/流水,不重新按当前 Fund EOD 重建数量和价格。 + // 这是为了保持待复核事件可重放的一致性,但也意味着申请后发生除权时仍可能带入冻结的旧基线; + // 直接提交路径的 EOD 复核不覆盖此审批路径。 swapEvent.unwindData = JsonConvert.DeserializeObject(swapEvent.EventData); NormalizeEventUnwindDate(swapEvent.unwindData); NormalizeNotionalValues(swapEvent.unwindData); @@ -2177,6 +2452,8 @@ namespace YLErp.Modules.SwapModule throw new ServiceException("未找到交易信息"); } NormalizeEventUnwindDate(unwindData); + // 进入审批申请时保存的是前端冻结的事件数据;当前路径不执行直接 SwapUnwind 的 Fund EOD 复核。 + // 因而申请发生在除权前、审批发生在除权后的场景,冻结数据仍是旧基线,需重新发起申请才能刷新。 if (eventType == (int)SwapEventTypeEnum.互换) { ValidateIncomeValueDate(unwindData, td); diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index e445a357..aeddd68a 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -14,6 +14,7 @@ using YLErp.Modules.DataProviderModule; using YLErp.Modules.EodModule; using YLErp.Modules.SwapModule.Margin; using YLErp.Modules.SwapModule.ReturnLegs; +using YLErp.Modules.TradeModule.DealModule; using YLErp.QdpModule; namespace YLErp.Modules.SwapModule @@ -33,6 +34,118 @@ namespace YLErp.Modules.SwapModule } + /// + /// 在一组日终快照中选择严格早于指定日期的最近日。 + /// 回退到除权日 D 时必须得到 D 之前的基线;若使用 D 自身,除权后的 2000/50 + /// 会被当成除权前状态,重收盘时就可能再次套用 10 送 10。严格使用 < valueDate + /// 也覆盖周末、节假日:周一没有周日 EOD 时,直接选择上一个实际有快照的交易日。 + /// + public static DateTime? SelectLatestEodDateBefore( + IEnumerable eodPositions, + DateTime valueDate) + { + if (eodPositions == null) + { + return null; + } + + var date = valueDate.Date; + return eodPositions + .Where(x => x != null && !x.Invalid && x.ValueDate.Date < date) + .Select(x => (DateTime?)x.ValueDate.Date) + .OrderByDescending(x => x.Value) + .FirstOrDefault(); + } + + /// + /// 返回指定交易在 valueDate 之前最近实际 EOD 日的全部有效明细。 + /// 这是回退和纯单元测试共用的选择规则;调用方不得退化为 AddDays(-1),因为自然日 + /// 不等于交易日。若没有快照返回空集合,表示只能保留当前实时持仓,不能伪造基线。 + /// + public static List SelectLatestEodPositionsBefore( + IEnumerable eodPositions, + DateTime valueDate) + { + var latestDate = SelectLatestEodDateBefore(eodPositions, valueDate); + if (!latestDate.HasValue) + { + return new List(); + } + + return eodPositions + .Where(x => x != null && !x.Invalid && x.ValueDate.Date == latestDate.Value.Date) + .ToList(); + } + + /// + /// 从已经确认的 Fund EOD 快照恢复实时浮动腿的有效基线。 + /// 该方法只复制 EOD 已落库的数量、价格、名义本金及累计分红/待结费用,不再次计算 + /// 公司行动系数,因此是幂等的。例:原 1000 份、期初价 100,10 送 10 后 EOD 为 + /// 2000 份、50;下一日盘中直接恢复 2000/50,不能再变成 4000/25。 + /// 非 Fund、空快照或标的腿不满足收取方向时返回 false,保持原有逻辑。 + /// + public static bool RestoreFundPositionFromEod( + swap_position realtimePosition, + eod_swap_position eodPosition) + { + if (realtimePosition == null + || eodPosition == null + || realtimePosition.PosiDirection <= 0 + || realtimePosition.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || eodPosition.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund) + { + return false; + } + + realtimePosition.PosiQuantity = eodPosition.PosiQuantity; + realtimePosition.PosiGrossPrice = eodPosition.PosiGrossPrice; + realtimePosition.PosiNetPrice = eodPosition.PosiNetPrice; + realtimePosition.PosiNetFeePrice = eodPosition.PosiNetFeePrice; + realtimePosition.PosiNetNoFeePrice = eodPosition.PosiNetNoFeePrice; + realtimePosition.PosiNotionalValue = Math.Round( + eodPosition.PosiNotionalValue, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + realtimePosition.PosiTradingFeePending = eodPosition.PosiFeePending; + realtimePosition.PosiDividendIncome = eodPosition.PosiDividendSum; + return true; + } + + /// + /// 查询 valueDate 之前最近一份有效 Fund EOD 快照,作为盘中操作的日初基线。 + /// 必须严格使用 < valueDate:试算日当天的 EOD 可能尚未完成,或是重收盘留下的待重建数据, + /// 不能反向覆盖盘中实时持仓。例:D 日 10 送 10 后 EOD 为 2000 份/50,D+1 盘中读取 D; + /// D 日盘中只读取 D-1,不会误把 D 日半成品当成已生效基线。Invalid 明细始终排除。 + /// 调用方还需检查该 EOD 之后是否已有完成流水,避免覆盖当日部分平仓结果。 + /// + public virtual eod_swap_position GetLatestValidEodPosition( + int swapTradeId, + long positionId, + DateTime valueDate) + { + return DbContext.eod_swap_position + .Where(x => x.SwapTradeId == swapTradeId + && x.PositionId == positionId + && !x.Invalid + && x.ValueDate < valueDate.Date) + .OrderByDescending(x => x.ValueDate) + .ThenByDescending(x => x.id) + .FirstOrDefault(); + } + + /// + /// 回退/收益互换等路径需要的最近实际 EOD 快照集合;严格早于 valueDate,且过滤作废行。 + /// + public virtual List GetLatestEodPositionsBefore( + int swapTradeId, + DateTime valueDate) + { + var candidates = DbContext.eod_swap_position + .Where(x => x.SwapTradeId == swapTradeId && !x.Invalid && x.ValueDate < valueDate.Date) + .ToList(); + return SelectLatestEodPositionsBefore(candidates, valueDate); + } + private int GetStorageDeliveryPriceRound(string underlyingInstrumentType, string underlyingCode) { if (ConsGlobal.InstrumentType.IsBond(underlyingInstrumentType)) @@ -316,6 +429,51 @@ namespace YLErp.Modules.SwapModule .ToList(); } + /// + /// 查找结算日有效的公司行为记录;测试可替换为内存数据。 + /// settleDate 必须是收盘作业使用的日期边界(通常为 00:00:00),这里沿用完整 + /// DateTime 相等匹配;历史数据若带时分秒或为空,不会被静默归入当天,需在作业前 + /// 通过数据预检查处理,而不是让收盘在错误基线上继续计算。 + /// + protected virtual List FindExDividendInfos(DateTime settleDate) + { + return DbContext.ex_dividend_info + .Where(x => x.ValidStatus + && x.EffectiveDate.HasValue + && x.EffectiveDate.Value == settleDate.Date) + .ToList(); + } + + /// + /// 获取公司行为公式使用的收盘价。 + /// EffectiveDate 是真正切换持仓基线的日期,但除权系数的收盘价仍属于登记日 + /// ExDividendDate;不能在 8 月 17 日 EOD 误取 8 月 17 日收盘价重算 8 月 14 日 + /// 登记日形成的系数。测试实现可以返回快照中的回退值,生产实现从登记日行情读取。 + /// + protected virtual decimal GetFundCorporateActionClosePrice( + ex_dividend_info dividendInfo, + decimal fallbackPrice) + { + if (!dividendInfo.ExDividendDate.HasValue) + { + return fallbackPrice; + } + + var closePrice = new EodPriceProvider(dividendInfo.ExDividendDate.Value) + .GetPrice(dividendInfo.UnderlyingCode, SettlementTypeEnum.ClosePrice); + return Convert.ToDecimal(closePrice); + } + + /// + /// 公司行为现金分红使用的系统税率,小数形式。 + /// 系统配置按百分数存储(例如 13 表示 13%),公司行为公式需要 0.13;送股本身 + /// 不受该税率影响;此处只负责读取并换算,不在异常时擅自默认为 0。 + /// + protected virtual decimal GetDividendTaxRate() + { + return new DividendService(this).GetDividendTaxRateDecimal(); + } + #endregion /// @@ -362,6 +520,20 @@ namespace YLErp.Modules.SwapModule var tradeExtendList = FindTradeExtends(tradeIds); var eodSwapList = FindEodSwapsByDate(preSettleDate); var completedFlowEvents = FindCompletedFlowEvents(tradeIds); + // 公司行为只取 settleDate 当天的有效单行;同一标的出现多条记录必须中止本次收盘, + // 否则 ToDictionary 会抛重复键,无法证明哪一条系数应生效。 + var exDividendInfos = FindExDividendInfos(settleDate); + var duplicateDividend = exDividendInfos + .GroupBy(x => x.UnderlyingCode, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(x => x.Count() > 1); + if (duplicateDividend != null) + { + throw new InvalidOperationException($"标的【{duplicateDividend.Key}】在【{settleDate:yyyy-MM-dd}】存在多条有效除权记录"); + } + var exDividendByCode = exDividendInfos.ToDictionary( + x => x.UnderlyingCode, + x => x, + StringComparer.OrdinalIgnoreCase); List eventTyps = new List() { (int)SwapEventTypeEnum.平仓, (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; foreach (var td in tradeQueryList) { @@ -401,8 +573,24 @@ namespace YLErp.Modules.SwapModule var flowEvents = FindFlowEvents(td.id, settleDate); var preDealDate = GetPreDealDate(td.id, settleDate, eventTyps);//上一次平仓/互换/自动互换处理日期 List autoInterests = new List();//自动互换利息腿信息 - //处理浮动腿 - var curEodPosis = DealFloatPositions(posiList, realPosiList, eodPositions, todyEodPositions, settleDate, td, preSettleDate, flowEvents); + // 处理浮动腿前先准备当日开盘基线:登记日 8 月 14 日 EOD 仍保存 + // 1000 份/100 元,8 月 17 日收盘时先把上一 EOD 的基线转换为 + // 2000 份/50 元,再处理当日平仓 300 份,最终才会得到 1700 份/50 元。 + // 不能等 DealFloatPositions 处理完平仓后再把 700 份乘 2,否则会错误得到 + // 1400 份;也不能直接修改数据库里的上一 EOD,否则登记日报表会被污染。 + var openingEodPositions = PrepareFundOpeningEodPositions( + eodPositions, + exDividendByCode, + settleDate); + var curEodPosis = DealFloatPositions( + posiList, + realPosiList, + openingEodPositions, + todyEodPositions, + settleDate, + td, + preSettleDate, + flowEvents); var posiLongNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Long).Sum(s => s.PosiNotionalValue); var posiShortNotional = curEodPosis.Where(s => s.PositionType == (int)PositionTypeFlag.Short).Sum(s => s.PosiNotionalValue); var closePosiNotional = curEodPosis.Where(s => s.TdCloseQty > 0).Sum(s => s.TdCloseQty * s.ContractSize * s.PosiGrossPrice); @@ -432,6 +620,256 @@ namespace YLErp.Modules.SwapModule } } + + /// + /// 把上一实际 EOD 复制成“当日开盘基线”,并在需要时套用当日生效的 Fund 公司行为。 + /// 原始上一 EOD 只读保留在数据库中,确保登记日 EOD 报表仍展示除权前 Q/P。 + /// 例如 1000 份/100 元、10 送 10 的记录在 8 月 14 日 EOD 仍是 1000/100; + /// 8 月 17 日处理当日流水前,内存基线先转为 2000/50,再平仓 300 份得到 1700/50。 + /// + protected List PrepareFundOpeningEodPositions( + IReadOnlyCollection previousEodPositions, + IReadOnlyDictionary exDividendByCode, + DateTime settleDate) + { + // 首日收盘或者当前非生效日 跳过 + if (previousEodPositions == null || previousEodPositions.Count == 0 + || exDividendByCode == null || exDividendByCode.Count == 0) + { + return previousEodPositions?.ToList() ?? new List(); + } + + // Clone 后只调整本次收盘的内存输入,不改 DbContext 跟踪的上一日实体; + // 否则重收盘或报表读取会把登记日的 Q/P 永久变成除权后 Q/P。 + var openingPositions = previousEodPositions + .Where(x => x != null) + .Select(x => x.Clone()) + .ToList(); + // 应用公司行为 + ApplyFundCorporateActions( + openingPositions, + Array.Empty(), + Array.Empty(), + exDividendByCode, + settleDate); + return openingPositions; + } + + /// + /// 对 Fund 浮动腿应用一条已按 EffectiveDate 筛选的公司行为。 + /// 此方法用于直接测试/兼容已有调用方;正式收盘链路通过 + /// PrepareFundOpeningEodPositions 在处理当日流水前执行同一动作。 + /// 该步骤只改 EOD 持仓,不生成现金分红流水;现金分红通过期初价下调进入浮动端损益, + /// 若同时再写 TdPosiDividend 会重复计入。 + /// + /// 幂等例子:原持仓 1000 份、期初价 100,每 10 份送 10 份。首次收盘得到 2000 份/50; + /// 同日重跑时,若该腿没有新流水,先从前一日 EOD 恢复 1000/100,再计算为 2000/50, + /// 不能直接在当日结果上再次计算成 4000/25。 + /// + /// + /// 有流水时不在这里强行恢复前一日数量,因为 DealFloatPositions 已把当日开平仓滚动到当前结果; + /// 盘中平仓不会再次套公式,而是读取严格早于 valueDate 的最近有效 EOD,必要时按当日 + /// EffectiveDate 再生成开盘基线。 + /// + /// + protected void ApplyFundCorporateActions( + IEnumerable positions, + IReadOnlyCollection previousEodPositions, + IReadOnlyCollection flowEvents, + IReadOnlyDictionary exDividendByCode, + DateTime settleDate) + { + if (exDividendByCode.Count == 0) + { + return; + } + + // 获取系统税率 + var dividendTaxRate = GetDividendTaxRate(); + foreach (var position in positions) + { + if (position.PosiDirection <= 0 + || position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || string.IsNullOrWhiteSpace(position.UnderlyingCode) + || !exDividendByCode.TryGetValue(position.UnderlyingCode, out var dividendInfo) + || !dividendInfo.EffectiveDate.HasValue + || dividendInfo.EffectiveDate.Value.Date != settleDate.Date) + { + continue; + } + + // 获取除权参考价 + var corporateActionClosePrice = GetFundCorporateActionClosePrice( + dividendInfo, + position.UnderlyingPrice); + if (corporateActionClosePrice <= 0) + { + throw new InvalidOperationException( + $"Fund 标的【{position.UnderlyingCode}】登记日【{dividendInfo.ExDividendDate:yyyy-MM-dd}】缺少有效收盘价,无法执行除权"); + } + + // 当天已有 EOD 且该腿没有流水时,Copy 分支不会恢复价格字段。 + // 先还原前一日基线,避免同一结算日重跑时再次除权。 + var hasPositionFlow = flowEvents.Any(x => x.PositionId == position.PositionId); + var previousPosition = previousEodPositions.FirstOrDefault( + x => x.PositionId == position.PositionId); + // 没有流水 且 有前一日持仓时,恢复前一日价格字段 + if (!hasPositionFlow && previousPosition != null) + { + position.PosiQuantity = previousPosition.PosiQuantity; + position.PosiGrossPrice = previousPosition.PosiGrossPrice; + position.PosiNetPrice = previousPosition.PosiNetPrice; + position.PosiNetFeePrice = previousPosition.PosiNetFeePrice; + position.PosiNetNoFeePrice = previousPosition.PosiNetNoFeePrice; + } + + var denominator = 10m + dividendInfo.GiveShareAmount + dividendInfo.RationedSharesAmount; + if (denominator == 0) + { + throw new InvalidOperationException( + $"Fund 标的【{position.UnderlyingCode}】在【{settleDate:yyyy-MM-dd}】的除权份额参数导致除数为 0"); + } + + var factors = DividendService.CalculateCorporateActionFactors( + dividendInfo, + corporateActionClosePrice, + dividendTaxRate); + if (factors.PriceRatio <= 0 || factors.ShareFactor <= 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。 + var originalQuantity = position.PosiQuantity; + position.PosiQuantity = Math.Round( + originalQuantity * factors.ShareFactor, + 12, + MidpointRounding.AwayFromZero); + position.TdChangedQty = position.PosiQuantity - originalQuantity; + + var storagePriceRound = GetStorageDeliveryPriceRound( + position.UnderlyingInstrumentType, + position.UnderlyingCode); + position.PosiGrossPrice = Math.Round( + position.PosiGrossPrice / factors.PriceRatio, + storagePriceRound, + MidpointRounding.AwayFromZero); + position.PosiNetPrice = Math.Round( + position.PosiNetPrice / factors.PriceRatio, + ConsGlobal.PriceRound, + MidpointRounding.AwayFromZero); + if (position.PosiNetFeePrice.HasValue) + { + position.PosiNetFeePrice = Math.Round( + position.PosiNetFeePrice.Value / factors.PriceRatio, + ConsGlobal.PriceRound, + MidpointRounding.AwayFromZero); + } + if (position.PosiNetNoFeePrice.HasValue) + { + position.PosiNetNoFeePrice = Math.Round( + position.PosiNetNoFeePrice.Value / factors.PriceRatio, + ConsGlobal.PriceRound, + MidpointRounding.AwayFromZero); + } + + var shortRatio = DirectionRatio.LongShort(position.PositionType); + var directionRatio = DirectionRatio.ReceivePay(position.PosiDirection); + position.PosiNotionalValue = Math.Round( + position.PosiGrossPrice * position.PosiQuantity * position.ContractSize, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + position.UnderlyingMarketValue = MtmCalc.MarketValue( + position.UnderlyingPrice, + position.PosiQuantity, + position.ContractSize, + shortRatio); + position.PosiMtmPnL = RoundMoney(MtmCalc.UnrealizedPnl( + position.UnderlyingPrice, + position.PosiGrossPrice, + position.PosiQuantity, + position.ContractSize, + shortRatio, + directionRatio)); + position.PosiProfitSum = RoundMoney(MtmCalc.ReturnLegProfitSum( + position.PosiMtmPnL, + position.PosiDividendSum, + position.PosiFeePending)); + position.SwapPositionValue = RoundMoney(PositionValueCalc.Calc( + position.InterestProfitSum, + position.PosiProfitSum)); + position.PosiStatus = position.PosiQuantity == 0 ? 1 : 0; + } + } + + /// + /// 将一条真实生效日公司行为应用到盘中实时 Fund 浮动腿。 + /// 盘中先复制严格早于 valueDate 的 EOD,再调用此方法;因此重复调用时每次都会 + /// 从同一份除权前 EOD 重新恢复,不会把 1000/100 重复变成 4000/25。 + /// 例:8 月 14 日 EOD 为 1000/100,8 月 17 日生效的 10 送 10 会得到 2000/50。 + /// + public static bool ApplyFundCorporateActionToPosition( + swap_position position, + ex_dividend_info dividendInfo, + decimal corporateActionClosePrice, + decimal dividendTaxRate) + { + if (position == null + || dividendInfo == null + || position.PosiDirection <= 0 + || position.UnderlyingInstrumentType != ConsGlobal.InstrumentType.Fund + || corporateActionClosePrice <= 0) + { + return false; + } + + var factors = DividendService.CalculateCorporateActionFactors( + dividendInfo, + corporateActionClosePrice, + dividendTaxRate); + if (factors.PriceRatio <= 0 || factors.ShareFactor <= 0) + { + throw new InvalidOperationException( + $"Fund 标的【{position.UnderlyingCode}】计算得到无效除权系数"); + } + + var originalQuantity = position.PosiQuantity; + position.PosiQuantity = Math.Round( + originalQuantity * factors.ShareFactor, + 12, + MidpointRounding.AwayFromZero); + position.PosiGrossPrice = Math.Round( + position.PosiGrossPrice / factors.PriceRatio, + ConsGlobal.SwapDeliveryPriceRound, + MidpointRounding.AwayFromZero); + position.PosiNetPrice = Math.Round( + position.PosiNetPrice / factors.PriceRatio, + ConsGlobal.PriceRound, + MidpointRounding.AwayFromZero); + if (position.PosiNetFeePrice.HasValue) + { + position.PosiNetFeePrice = Math.Round( + position.PosiNetFeePrice.Value / factors.PriceRatio, + ConsGlobal.PriceRound, + MidpointRounding.AwayFromZero); + } + if (position.PosiNetNoFeePrice.HasValue) + { + position.PosiNetNoFeePrice = Math.Round( + position.PosiNetNoFeePrice.Value / factors.PriceRatio, + ConsGlobal.PriceRound, + MidpointRounding.AwayFromZero); + } + position.PosiNotionalValue = Math.Round( + position.PosiGrossPrice * position.PosiQuantity * position.ContractSize, + ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + return true; + } + /// /// 框架合约汇总 /// @@ -688,6 +1126,10 @@ namespace YLErp.Modules.SwapModule var hasDividend = curEodPositions.Any(x => x.PosiDividendSum != 0); if (!hasDividend) return; + // ApplyFundCorporateActions 已经把 Fund 的现金分红写入除权后的期初价格/名义本金; + // 这里处理的是持仓期间累计的付息/分红结算流水。两者同时把同一现金再写入 + // PosiDividendSum 会重复实现,故公司行为步骤不会在此处直接填充该字段。 + var dividendPayDateOffset = tradeExtend?.ExtendObj?.DividendPayDate ?? 1; if (dividendPayDateOffset <= 0) return; diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs index 26a57cee..52e14f22 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeService.cs @@ -1538,6 +1538,9 @@ namespace YLErp.Modules.SwapModule } else { + // 按日期回退时只恢复 valueDate 之前最近实际收盘的快照;当天及以后数据会在 + // InvalidTradeOptionDatasByDate 中清理。这样回退到除权日 D 会回到 D-1 的 + // 1000 份/100 基线并让重收盘重新应用公司行为;回退到 D+1 则保留 D 的 2000 份/50。 TradeBackByDate(td, valueDate, swapPositions); } if (swapEvent != null)//展期 @@ -1673,8 +1676,14 @@ namespace YLErp.Modules.SwapModule { SwapEodPositionService eodPositionService = new SwapEodPositionService(this); SwapDealService swapDealService = new SwapDealService(this); - var preDay = valueDate.AddDays(-1); - var eodSwapPositionList = DbContext.eod_swap_position.Where(x => x.ValueDate == preDay && x.SwapTradeId == td.id).ToList(); + // 回退基线必须是 valueDate 之前最近一个实际 EOD,而不是 valueDate-1 自然日。 + // 例如周一/节假日后的 valueDate 没有周日 EOD 时,AddDays(-1) 会得到空集合, + // 实时腿仍保留除权后的数量/价格。回退到除权日 D 选择 D 前基线并由下面的 + // InvalidTradeOptionDatasByDate 删除 D 及以后 EOD;回退到 D+1 则会选择 D, + // 保留 D 已生效的公司行为。ex_dividend_info 本身不删除,重收盘 D 会再应用一次。 + var eodSwapPositionList = eodPositionService.GetLatestEodPositionsBefore(td.id, valueDate); + // 没有 valueDate 之前的有效 EOD 时,eodSwapPositionList 为空;这是“没有可证明基线”的情况, + // 下面不会伪造数量/价格或重算公司行为,只保留当前实时持仓并继续清理回退日之后的数据。 var swapFlowEvents = DbContext.swap_flow_event.Where(x => x.SwapTradeId == td.id && x.EventDate >= valueDate && x.DataState > (int)SwapFlowDateStateEnum.废弃).ToList(); var positions = swapPositions.Where(x => x.PosiDirection > 0 && !x.IsInitial).ToList(); @@ -1700,6 +1709,8 @@ namespace YLErp.Modules.SwapModule td.TradeAmount = Convert.ToDouble(posi.PosiQuantity); } } + // eodPosi 为空时刻意不改 posi:回退只能使用已落库的历史快照,不能把缺失数据 + // 猜成 0 或交易初始值,否则会把未验证的 Fund 除权数量带入后续收盘。 } td.UnWindDate = null; td.UnWindNotional = null; @@ -1740,6 +1751,10 @@ namespace YLErp.Modules.SwapModule /// private void InvalidTradeOptionDatasByDate(int tradeId, DateTime valueDate, bool backToBegin) { + // 所有清理条件都采用闭区间起点 [valueDate, +∞):回退到除权日 D 要删除 D 当天 + // 已应用的 EOD/流水,随后重收盘 D 才会从 D-1 快照重新套一次系数;回退到 D+1 + // 不会删除 D,因而保留 D 已生效的 2000 份/50。valueDate 之前的快照始终保留, + // 作为 TradeBackByDate 的唯一可验证基线。 var swapEvents = DbContext.swap_event.Where(x => !x.Invalid && x.SwapTradeId == tradeId && x.ValueDate >= valueDate).ToList(); var swapEodPositions = DbContext.eod_swap_position.Where(x => x.SwapTradeId == tradeId && x.ValueDate >= valueDate); var swapEods = DbContext.eod_swap.Where(x => x.SwapTradeId == tradeId && x.ValueDate >= valueDate); diff --git a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs index ce9a9714..598b89c5 100644 --- a/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs +++ b/YLErpDAL/Modules/TradeModule/DealModule/DividendService.cs @@ -20,6 +20,11 @@ namespace YLErp.Modules.TradeModule.DealModule } + public DividendService(YLBaseService baseService) : base(baseService) + { + + } + /// /// 场内交易除权除息 /// @@ -80,7 +85,9 @@ namespace YLErp.Modules.TradeModule.DealModule var dict = GetExDividendQuery(settleDate) .ToDictionary(K => K.UnderlyingId, V => V); var tradeIds = trades.Select(O => O.id); - var dividendRatioDict = new DbRecordChangesService(this).GetValue(ConsInfoChangeType.UserChange, tradeIds, nameof(trade.DividendRatio), settleDate).ToDictionary(K => K.RecordId, V => { return double.TryParse(V.NewValue, out var temp) ? (double?)temp : null; }); + var dividendRatioDict = new DbRecordChangesService(this) + .GetValue(ConsInfoChangeType.UserChange, tradeIds, nameof(trade.DividendRatio), settleDate) + .ToDictionary(K => K.RecordId, V => { return double.TryParse(V.NewValue, out var temp) ? (double?)temp : null; }); foreach (var t in trades) { var bodTrade = new bod_trade(); @@ -102,7 +109,9 @@ namespace YLErp.Modules.TradeModule.DealModule { annualizeFactor = t.trade_snowball.AnnualizeFactor2; } - t.StockEqvNotionalReal = t.StockEqvNotionalReal == 0 ? TradeHelper.GetStockEqvNotionalReal(t.OriginalStockEqvNotional, t.ParticipationRate, annualizeFactor) : t.StockEqvNotionalReal; + t.StockEqvNotionalReal = t.StockEqvNotionalReal == 0 + ? TradeHelper.GetStockEqvNotionalReal(t.OriginalStockEqvNotional, t.ParticipationRate, annualizeFactor) + : t.StockEqvNotionalReal; t.OriginalNotional = t.StockEqvNotionalReal / t.SpotPrice; t.TradeOriginalAmount = t.OriginalNotional / (t.CountRatio ?? 1); //不管是不是名义本金方式了结,都应该按照比例了结。--时嬴政 @@ -722,6 +731,7 @@ namespace YLErp.Modules.TradeModule.DealModule { return 0; } + // 价格 / 系数 var result = (decimal)price / decimalRatio; return (double)Math.Round(result, 4, MidpointRounding.AwayFromZero); } @@ -736,14 +746,71 @@ namespace YLErp.Modules.TradeModule.DealModule return (double)GetRatioDecimal(info); } + internal readonly struct CorporateActionFactors + { + public CorporateActionFactors(decimal priceRatio, decimal shareFactor) + { + PriceRatio = priceRatio; + ShareFactor = shareFactor; + } + + public decimal PriceRatio { get; } + public decimal ShareFactor { get; } + } + + /// + /// 统一计算公司行为的价格系数和数量系数。价格系数沿用原股票除权公式; + /// 数量仅受送股/拆合股影响,配股仍只进入价格公式,保持现有业务口径不变。 + /// + /// 送股例子:收盘价 100、每 10 份送 10 份、无现金/配股时,除权参考价为 50, + /// PriceRatio=100/50=2,ShareFactor=2。调用方据此把 1000 份/期初价 100 调整为 + /// 2000 份/50;数量与价格反向变化,期初名义本金仍为 100000。 + /// + /// + /// 现金例子:收盘价 100、每 10 份派现 10、税率 0 时,除权参考价为 99, + /// ShareFactor 仍为 1,所以数量不变,只把期初价按 100/99 的价格系数下调。 + /// 本方法只返回系数,不修改持仓,也不判断公司行动是否已经执行;幂等边界由调用方保证。 + /// + /// + internal static CorporateActionFactors CalculateCorporateActionFactors( + ex_dividend_info info, + decimal closePrice, + decimal dividendRate) + { + // (收盘价 * 10) - 现金分红 * (1 - 税率) + (配股数量 * 配股价格) + // ------------------------------------------------------- + // (10 + 送股数量 + 配股数量) + var exDividendPrice = (closePrice * 10m - info.GiveCashAmount * (1m - dividendRate) + + info.RationedSharesAmount * info.RationedSharesPrice) + / (10m + info.GiveShareAmount + info.RationedSharesAmount); + var priceRatio = exDividendPrice == 0 ? 0 : closePrice / exDividendPrice; + var shareFactor = 1m + info.GiveShareAmount / 10m; + return new CorporateActionFactors(priceRatio, shareFactor); + } + + /// + /// 将系统配置中的百分数税率转换为公司行为公式使用的小数税率。 + /// 例如配置 13 表示 13%,返回 0.13;送股系数不使用该税率,只有现金分红的税后金额使用。 + /// + internal decimal GetDividendTaxRateDecimal() + { + return (decimal)valuedateBLL.SystemDate.DividendRate / 100m; + } + + /** + * 在没有现金分红和配股时: + * 拆合股:10*closePrice / 10+GiveShareAmount + * 调整后数量 = 原数量 × 除权系数 + * 调整后价格 = 原价格 ÷ 除权系数 + */ private decimal GetRatioDecimal(ex_dividend_info info) { - var dividendRate = (decimal)valuedateBLL.SystemDate.DividendRate / 100m; + // 除权系数依赖除权登记日收盘价;调用方若在收盘前或使用非标准日期调用, + // EodPriceProvider 可能拿不到价格并返回无效系数,不能把该情况默认为 1。 + var dividendRate = GetDividendTaxRateDecimal(); var closePrice = new EodPriceProvider(info.ExDividendDate.Value).GetPrice(info.UnderlyingCode, SettlementTypeEnum.ClosePrice); var decimalClosePrice = (decimal)closePrice; - var cDivdPrice = (decimalClosePrice * 10m - (info.GiveCashAmount * (1m - dividendRate)) + info.RationedSharesAmount * info.RationedSharesPrice) / - (10m + info.GiveShareAmount + info.RationedSharesAmount); - return cDivdPrice == 0 ? 0 : decimalClosePrice / cDivdPrice; + return CalculateCorporateActionFactors(info, decimalClosePrice, dividendRate).PriceRatio; } /// @@ -766,12 +833,16 @@ 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); return (double)Math.Round(result, 12, MidpointRounding.AwayFromZero); } public IQueryable GetExDividendQuery(DateTime valueDate) { + // 该查询沿用作业的“日期已归一化”约定,要求 valueDate 与存量 ExDividendDate + // 同为当天 00:00;自然日业务键的时分秒兼容由保存路径 FindExDividendByBusinessKey 负责。 return DbContext.ex_dividend_info .Where(O => O.ValidStatus && O.ExDividendDate == valueDate); } @@ -810,7 +881,9 @@ namespace YLErp.Modules.TradeModule.DealModule public void ImportDividendInfos(Stream stream) { var dt = new ExcelHelper().ExcelToDataTable(stream, null, true); - if (!dt.Columns.Contains("股票代码") || !dt.Columns.Contains("股权登记日")) + if (!dt.Columns.Contains("股票代码") + || !dt.Columns.Contains("股权登记日") + || !dt.Columns.Contains("真实除权日")) { throw new ServiceException("请使用正确的模板上传"); } @@ -821,6 +894,10 @@ namespace YLErp.Modules.TradeModule.DealModule { UnderlyingCode = dt.Rows[i]["股票代码"]?.ToString(), ExDividendDate = DateTime.TryParse(getColValueFromTable(dt.Rows[i], "股权登记日"), out var date) ? date : DateTime.MinValue, + EffectiveDate = DateTime.TryParse( + getColValueFromTable(dt.Rows[i], "真实除权日"), out var effectiveDate) + ? effectiveDate + : (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, RationedSharesAmount = decimal.TryParse(getColValueFromTable(dt.Rows[i], "配股股数"), out value) ? value : 0, @@ -841,6 +918,14 @@ namespace YLErp.Modules.TradeModule.DealModule { throw new ServiceException($"第{i + 1}行股权登记日不正确"); } + if (!info.EffectiveDate.HasValue) + { + throw new ServiceException($"第{i + 1}行真实除权日不正确"); + } + if (info.EffectiveDate.Value.Date < info.ExDividendDate.Value.Date) + { + throw new ServiceException($"第{i + 1}行真实除权日不应早于股权登记日"); + } dividendInfos.Add(info); } if (!AddDividendInfos(dividendInfos, out var errMsg)) @@ -900,6 +985,12 @@ namespace YLErp.Modules.TradeModule.DealModule { target.RationedSharesPrice = source.RationedSharesPrice; } + if (source.EffectiveDate.HasValue) + { + // EffectiveDate 是日期语义,导入/接口可能带时分秒;统一只保留自然日。 + // 为空时不覆盖数据库已有值,避免旧记录在不完整导入中丢失真实生效日。 + target.EffectiveDate = source.EffectiveDate.Value.Date; + } } public bool AddDividendInfos(IEnumerable infos, out string errMsg) @@ -939,6 +1030,10 @@ namespace YLErp.Modules.TradeModule.DealModule // 保存前统一截断时间部分,确保 Excel/接口传入的同一天不同时间 // 能命中同一个自然日业务键,也与数据库的一行模型保持一致。 var exDividendDate = item.ExDividendDate.Value.Date; + if (item.EffectiveDate.HasValue) + { + item.EffectiveDate = item.EffectiveDate.Value.Date; + } var businessKey = (underlying.id, exDividendDate); if (item.id > 0 && recordKeys.TryGetValue(item.id, out var existingRecordKey) diff --git a/YLErpWeb/Controllers/ex_dividend_infoController.cs b/YLErpWeb/Controllers/ex_dividend_infoController.cs index a366b8ae..b5c7e0d4 100644 --- a/YLErpWeb/Controllers/ex_dividend_infoController.cs +++ b/YLErpWeb/Controllers/ex_dividend_infoController.cs @@ -46,14 +46,24 @@ namespace YLErp.Web.Controllers { throw new FormatException("未收到参数"); } - if (string.IsNullOrWhiteSpace(info.UnderlyingCode) || info.ExDividendDate == null) + if (string.IsNullOrWhiteSpace(info.UnderlyingCode) + || info.ExDividendDate == null + || info.EffectiveDate == null) { - throw new FormatException("标的代码或股权登记日信息不存在!"); + throw new FormatException("标的代码、股权登记日或真实除权日信息不存在!"); } if (QdpCalendarHelper.IsHoliday(info.ExDividendDate.Value)) { throw new FormatException("股权登记日不应为非交易日!"); } + if (QdpCalendarHelper.IsHoliday(info.EffectiveDate.Value)) + { + throw new FormatException("真实除权日不应为非交易日!"); + } + if (info.EffectiveDate.Value.Date < info.ExDividendDate.Value.Date) + { + throw new FormatException("真实除权日不应早于股权登记日!"); + } var status = new DividendService(CurUser).AddDividendInfos(new[] { info }, out var errMsg); if (!status) { diff --git a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js index 8039367f..afee45e7 100644 --- a/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js +++ b/YLErpWeb/wwwroot/Scripts/app/underlying/underlyingDividendInfo.js @@ -18,6 +18,7 @@ function saveInfo(dataId, rowId) { id: dataId, UnderlyingCode: underlyingCode, ExDividendDate: $("#" + rowId + "_ExDividendDate").val(), + EffectiveDate: $("#" + rowId + "_EffectiveDate").val(), GiveCashAmount: $("#" + rowId + "_GiveCashAmount").val(), GiveShareAmount: $("#" + rowId + "_GiveShareAmount").val(), ConversionShareAmount: $("#" + rowId + "_ConversionShareAmount").val(), @@ -76,6 +77,7 @@ function gridComplete(obj) { id: 0, UnderlyingCode: underlyingCode, ExDividendDate: null, + EffectiveDate: null, GiveCashAmount: 0.0, GiveShareAmount: 0, RationedSharesAmount: 0, @@ -107,6 +109,8 @@ var colModelGrid = [{ name: 'EncryptId', label: 'EncryptId', index: 'EncryptId', width: 0, hidden: true }, { name: 'ExDividendDate', label: '股权登记日', index: 'ExDividendDate', width: 90, formatter: 'date', editable: true, classes: 'datepicker', editrules: { required: true, date: true }, +}, { + name: 'EffectiveDate', label: '真实除权日', index: 'EffectiveDate', width: 90, formatter: 'date', editable: true, classes: 'datepicker', editrules: { required: true, date: true }, }, { name: 'GiveCashAmount', label: '派息金额(10股)', index: 'GiveCashAmount', width: 100, formatter: { number: { decimalPlaces: 4, defaultValue: '0' } }, editable: true, editrules: { number: true }, }, {