using YLErp.DBModels; using YLErp.DBModels.Enums; 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] 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_股票与最新Eod后已有完成流水时保持实时持仓() { var nonFund = CreateRealtimeFundPosition(); nonFund.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock; var eod = CreateEod(ExDate, 2000m, 50m); Assert.IsTrue(SwapEodPositionService.RestoreFundPositionFromEod(nonFund, eod)); Assert.AreEqual(2000m, nonFund.PosiQuantity); Assert.AreEqual(50m, 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_008_股票TRS平仓恢复有效Eod基线() { var realtime = CreateRealtimeFundPosition(); realtime.UnderlyingCode = "STOCK.TEST"; realtime.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock; realtime.PosiQuantity = 1000m; realtime.PosiGrossPrice = 100m; realtime.PosiNetPrice = 100m; realtime.PosiNetFeePrice = 100m; realtime.PosiNetNoFeePrice = 100m; realtime.PosiNotionalValue = 100000m; var eod = CreateEod(ExDate, 2000m, 50m); eod.UnderlyingCode = "STOCK.TEST"; eod.UnderlyingInstrumentType = ConsGlobal.InstrumentType.Stock; var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false); var unwindData = CreateFullCloseUnwindData(); unwindData.ValueDate = ExDate; unwindData.UnwindDate = ExDate.AddDays(1); Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, ExDate)); Assert.AreEqual(2000m, realtime.PosiQuantity, "Stock TRS 生效日盘中平仓应使用有效 EOD 数量,不能继续使用除权前实时数量"); Assert.AreEqual(50m, realtime.PosiGrossPrice, "Stock TRS 生效日盘中平仓应使用有效 EOD 价格"); Assert.AreEqual(2000m, unwindData.PositionQty); Assert.AreEqual(2000m, unwindData.CloseQty); Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice); } [TestMethod] public void FCA_UW_005_生效日盘中恢复前一Eod后再套除权() { 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_009_登记日跨非交易日到生效日按范围恢复基金基线() { var eodDate = new DateTime(2026, 7, 12); var effectiveDate = new DateTime(2026, 7, 13); var unwindDate = new DateTime(2026, 7, 17); var realtime = CreateRealtimeFundPosition(); var eod = CreateEod(eodDate, 1000m, 100m); var service = CreateService(SwapDealTestFactory.CreateTrade(), realtime, eod, hasCompletedFlow: false); service.ExDividendInfos.Add(new ex_dividend_info { id = 1, UnderlyingCode = "FUND.TEST", // 7/10 登记,7/13 生效;7/11、7/12 虽无交易但仍可能存在未除权 EOD 快照。 ExDividendDate = new DateTime(2026, 7, 10), EffectiveDate = effectiveDate, // 生产数据口径:1 拆 2 直接存 Split=2,GiveShareAmount 不参与该拆分。 GiveShareAmount = 0m, Split = 2m, ValidStatus = true }); var unwindData = CreateFullCloseUnwindData(); unwindData.ValueDate = unwindDate; unwindData.UnwindDate = unwindDate; Assert.IsTrue(service.RestoreEffectiveFundPositionForTest(unwindData, unwindDate)); Assert.AreEqual(2000m, realtime.PosiQuantity, "7 月 17 日平仓应补应用 7 月 13 日生效的 Split=2,公司行为不能只按平仓日命中"); Assert.AreEqual(50m, realtime.PosiGrossPrice); Assert.AreEqual(2000m, unwindData.PositionQty); Assert.AreEqual(2000m, unwindData.CloseQty); Assert.AreEqual(50m, unwindData.FlowEvents.Single().PosiGrossPrice); } [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; } } }