using YLErp.DBModels; using YLErp.DBModels.Enums; namespace YLErp.Modules.SwapModule { /// /// DealFloatPositions 浮动腿收盘归档 - 合成单元测试 /// ============================================================================ /// DealFloatPositions 处理浮动腿(标的持仓)的日终归档,三个分支: /// ① 无前日eod → SaveCurrentEodInitalPosi(首日初始化,纯计算) /// ② 有eod无平仓 → CopyEodPosition(复制+更新价格,依赖外部数据源) /// ③ 有eod有平仓 → UpdateEodPosition(更新持仓,依赖外部数据源) /// /// 当前可测范围: /// - 分支选择逻辑(DealFloatPositions 调度层,纯内存) /// - SaveCurrentEodInitalPosi(首日初始化,纯计算,无外部依赖) /// CopyEodPosition/UpdateEodPosition 需额外接缝(UnderlyingCodePrice等),留后续。 /// ============================================================================ [TestClass] public class DealFloatPositionsScenarioTest { private const int SwapTradeId = 200; private static readonly DateTime TradeDate = new(2026, 4, 28); private static readonly DateTime PreSettleDate = new(2026, 4, 27); #region Stub private sealed class StubEodService : SwapEodPositionService { // 可注入的外部数据 public decimal UnderlyingPrice { get; set; } = 1.00m; public decimal Vobp { get; set; } = 0m; public decimal BondPayment { get; set; } = 0m; public decimal TaxRate { get; set; } = 0m; public string UnderlyingCode { get; set; } = "210210.IB"; public StubEodService() : base(new OptUserInfo(0, nameof(DealFloatPositionsScenarioTest), OptUserFrom.UnitTest)) { } // override 外部依赖 protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp) { vobp = Vobp; return UnderlyingPrice; } protected override underlying_manager GetUnderlyingData(string underlyingCode) { return new underlying_manager { ValueAddedTax = TaxRate }; } protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) { return BondPayment; } protected override void SaveAllChanges() { } protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType) => 1.0; // DealFloatPositions 和子方法都是 protected,通过 public 包装暴露 public List ExecuteDealFloatPositions( List posiList, List realPosiList, List eodPositions, List todyEodPositions, DateTime settleDate, trade td, DateTime preSettleDate, List flowEvents) { return DealFloatPositions(posiList, realPosiList, eodPositions, todyEodPositions, settleDate, td, preSettleDate, flowEvents); } public eod_swap_position ExecuteSaveCurrentEodInitalPosi( swap_position position, trade td, DateTime settleDate, DateTime preSettleDate, List unwindEvents) { return SaveCurrentEodInitalPosi(position, td, settleDate, preSettleDate, unwindEvents); } public eod_swap_position ExecuteCopyEodPosition( eod_swap_position eod, eod_swap_position curretEod, trade td, DateTime valueDate, DateTime preSettleDate) { return CopyEodPosition(eod, curretEod, td, valueDate, preSettleDate); } } #endregion #region 数据构建 private static trade CreateTrade() { return new trade { id = SwapTradeId, TradeNumber = "UT-FLOAT-001", ClientId = 999998, TradeType = "收益互换", TradeDate = PreSettleDate, StartDate = PreSettleDate, ExerciseDate = new DateTime(2027, 4, 27), TradeStatus = "确认成交", ValidState = "Valid", StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY", OriginalStockEqvNotional = 10000 }; } private static swap_position CreateFloatPosition(int id = 3001, decimal qty = 10000m) { return new swap_position { id = id, SwapTradeId = SwapTradeId, PosiDirection = 2, PositionType = (int)PositionTypeFlag.Long, UnderlyingCode = "210210.IB", ContractSize = 1m, PosiQuantity = qty, PosiNotionalValue = qty, PosiNetPrice = 1.005m, PosiGrossPrice = 1.002m, PosiNetFeePrice = 1.004m, PosiNetNoFeePrice = 1.001m, IsInitial = true, Invalid = false, PosiTradingFee = 0, PosiTradingFeePending = 0 }; } private static swap_flow_event CreateCloseEvent(int positionId, decimal qty, decimal markClosePnl = 100m) { return new swap_flow_event { SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.平仓, PositionId = positionId, Quantity = qty, MarkClosePnl = markClosePnl, DividendIn = 0, CloseFee = 5m, TradingFeePending = 0, TradingAmount = qty * 1.002m, UnwindDate = TradeDate, EventDate = TradeDate, PayDate = TradeDate, DataState = (int)SwapFlowDateStateEnum.完成 }; } #endregion // ================================================================ // 场景1:空持仓列表 → 返回空列表 // ================================================================ [TestMethod] public void DF_001_空持仓返回空列表() { var service = new StubEodService(); var result = service.ExecuteDealFloatPositions( new List(), new List(), new List(), new List(), TradeDate, CreateTrade(), PreSettleDate, new List()); Assert.AreEqual(0, result.Count, "空持仓应返回空列表"); } // ================================================================ // 场景2:首日无前日eod → 走 SaveCurrentEodInitalPosi 分支 // ================================================================ /// /// 无前日eod(eodPositions 不含该持仓),应走 SaveCurrentEodInitalPosi。 /// SaveCurrentEodInitalPosi 是纯计算,验证基本字段正确。 /// [TestMethod] public void DF_002_首日无eod走初始化分支() { var service = new StubEodService(); var td = CreateTrade(); var position = CreateFloatPosition(); var result = service.ExecuteDealFloatPositions( new List { position }, new List { position }, new List(), // 无前日eod new List(), // 无当日eod TradeDate, td, PreSettleDate, new List()); // 无平仓事件 Assert.AreEqual(1, result.Count, "应生成1条浮动腿eod"); var eod = result[0]; Assert.AreEqual(position.id, eod.PositionId, "PositionId应匹配"); Assert.AreEqual(SwapTradeId, eod.SwapTradeId); Assert.AreEqual(TradeDate, eod.ValueDate); Console.WriteLine($"首日初始化: PosiQuantity={eod.PosiQuantity}, PosiNetPrice={eod.PosiNetPrice}"); } // ================================================================ // 场景3:SaveCurrentEodInitalPosi 直接验证(纯计算方法) // ================================================================ /// /// 直接测 SaveCurrentEodInitalPosi,验证它正确初始化 eod 的关键字段。 /// 这个方法无外部依赖(纯计算),可以精确验证值。 /// [TestMethod] public void DF_003_首日初始化字段正确() { var service = new StubEodService(); var td = CreateTrade(); var position = CreateFloatPosition(qty: 10000m); var eod = service.ExecuteSaveCurrentEodInitalPosi( position, td, TradeDate, PreSettleDate, new List()); // 验证关键字段 Assert.AreEqual(10000m, eod.PosiQuantity, "持仓数量应=初始数量"); Assert.AreEqual(1.005m, eod.PosiNetPrice, "含费均价应=持仓均价"); Assert.AreEqual(1.002m, eod.PosiGrossPrice, "不含费均价"); Assert.AreEqual((int)PositionTypeFlag.Long, eod.PositionType, "持仓类型"); Assert.AreEqual(SwapTradeId, eod.SwapTradeId, "交易ID"); Assert.AreEqual(td.ClientId, eod.ClientId, "客户ID"); Assert.AreEqual(0, eod.TdCloseQty, "首日无平仓数量"); Assert.AreEqual(0, eod.TdCloseMtmPnl, "首日无平仓盈亏"); Console.WriteLine($"首日初始化 eod: Qty={eod.PosiQuantity}, NetPrice={eod.PosiNetPrice}, GrossPrice={eod.PosiGrossPrice} ✅"); } // ================================================================ // 场景4:有前日eod无平仓 → 走 CopyEodPosition 分支 // ================================================================ /// /// 有前日eod但无平仓事件,应走 CopyEodPosition 分支。 /// CopyEodPosition 依赖外部数据源(DataCacheProvider/UnderlyingCodePrice), /// 测试验证分支选择正确(不验证值),且不抛异常。 /// [TestMethod] public void DF_004_有eod无平仓走Copy分支() { var service = new StubEodService(); var td = CreateTrade(); var position = CreateFloatPosition(); var preEod = new eod_swap_position { id = 5001, SwapTradeId = SwapTradeId, PositionId = position.id, ValueDate = PreSettleDate, PosiQuantity = 10000m, PosiNetPrice = 1.005m, PosiGrossPrice = 1.002m, UnderlyingCode = "210210.IB", ContractSize = 1m, PositionType = (int)PositionTypeFlag.Long, PosiDirection = 2 }; // CopyEodPosition 内部调 DataCacheProvider/UnderlyingCodePrice, // 这些连缓存可能返回null → 方法 cs:1492 if(um==null) return curretEod // 所以即使缓存没数据,也不会抛异常,只是字段不更新 try { var result = service.ExecuteDealFloatPositions( new List { position }, new List { position }, new List { preEod }, new List(), TradeDate, td, PreSettleDate, new List()); // 无平仓 Assert.AreEqual(1, result.Count, "应生成1条eod"); // um==null时 CopyEodPosition 直接返回 clone,字段不变 Assert.AreEqual(10000m, result[0].PosiQuantity, "无缓存时数量应=前日值"); Console.WriteLine($"Copy分支(无缓存): PosiQuantity={result[0].PosiQuantity}(保持前日值)"); } catch (Exception ex) { Assert.Inconclusive($"CopyEodPosition 依赖外部数据源,需额外接缝。异常: {ex.Message}"); } } // ================================================================ // 场景5:CopyEodPosition 盯市盈亏计算(注入固定标的价格) // ================================================================ /// /// [DF_005] 无平仓日,标的价格变动 → PosiMtmPnL 正确反映浮动盈亏 /// --------------------------------------------------------------- /// 前日持仓全价=1.002,当日标的价格=1.010(涨了)。 /// 多头收取方向,PosiMtmPnL = (1.010 - 1.002) × 10000 × 1(ContractSize) × 1(shortRatio) × 1(directionRatio) /// = 0.008 × 10000 = 80 /// [TestMethod] public void DF_005_Copy分支盯市盈亏计算() { var service = new StubEodService(); service.UnderlyingPrice = 1.010m; // 当日标的价格 service.TaxRate = 0m; service.BondPayment = 0m; var td = CreateTrade(); var position = CreateFloatPosition(); var preEod = new eod_swap_position { id = 5001, SwapTradeId = SwapTradeId, PositionId = position.id, ValueDate = PreSettleDate, PosiQuantity = 10000m, PosiGrossPrice = 1.002m, PosiNetPrice = 1.005m, UnderlyingCode = "210210.IB", ContractSize = 1m, PositionType = (int)PositionTypeFlag.Long, PosiDirection = (int)SwapDirectionEnum.收取, PosiDividendSum = 0m, PosiFeePending = 0m, PosiProfitSum = 0m }; var result = service.ExecuteCopyEodPosition(preEod, null, td, TradeDate, PreSettleDate); // PosiMtmPnL = (price - grossPrice) × qty × contractSize × shortRatio × directionRatio // 收取方向 directionRatio=1, 多头 shortRatio=1 // = (1.010 - 1.002) × 10000 × 1 × 1 × 1 = 80 AssertDecimalEqual(80m, result.PosiMtmPnL, 0.01m, "盯市盈亏"); Console.WriteLine($"Copy分支盯市: PosiMtmPnL={result.PosiMtmPnL}((1.010-1.002)×10000=80)✅"); } // ================================================================ // 场景6:CopyEodPosition 分红计算(注入固定付息) // ================================================================ /// /// [DF_006] 无平仓日,债券付息 → TdPosiDividend 和 PosiDividendSum 正确 /// --------------------------------------------------------------- /// 注入 BondPayment=100(付息总额),增值税率=0。 /// TdPosiDividend = 100 / (1+0) × (1-0) = 100。 /// PosiDividendSum = preEod.PosiDividendSum(0) + TdPosiDividend(100) = 100。 /// [TestMethod] public void DF_006_Copy分支分红计算() { var service = new StubEodService(); service.UnderlyingPrice = 1.002m; // 价格不变 service.TaxRate = 0m; service.BondPayment = 100m; // 付息100 var td = CreateTrade(); var position = CreateFloatPosition(); var preEod = new eod_swap_position { id = 5001, SwapTradeId = SwapTradeId, PositionId = position.id, ValueDate = PreSettleDate, PosiQuantity = 10000m, PosiGrossPrice = 1.002m, PosiNetPrice = 1.005m, UnderlyingCode = "210210.IB", ContractSize = 1m, PositionType = (int)PositionTypeFlag.Long, PosiDirection = (int)SwapDirectionEnum.收取, PosiDividendSum = 0m, PosiFeePending = 0m, PosiProfitSum = 0m }; var result = service.ExecuteCopyEodPosition(preEod, null, td, TradeDate, PreSettleDate); // TdPosiDividend = payment / (1+tax) × (1-tax) = 100 / 1 × 1 = 100 AssertDecimalEqual(100m, result.TdPosiDividend, 0.01m, "当日分红"); // PosiDividendSum = 前日(0) + 当日(100) = 100 AssertDecimalEqual(100m, result.PosiDividendSum, 0.01m, "待实现分红累计"); Console.WriteLine($"Copy分支分红: TdPosiDividend={result.TdPosiDividend}, PosiDividendSum={result.PosiDividendSum} ✅"); } // ================================================================ // 场景7:分红增值税调整(税率≠0) // ================================================================ /// /// [DF_007] 分红含增值税调整:BondPayment=100, tax=6%(0.06) /// --------------------------------------------------------------- /// TdPosiDividend = 100 / (1+0.06) × (1-0.06) = 100/1.06×0.94 ≈ 88.68 /// 验证增值税调整公式 cs:1508 payment / (1+tax) * (1-tax)。 /// [TestMethod] public void DF_007_分红增值税调整() { var service = new StubEodService(); service.UnderlyingPrice = 1.002m; service.TaxRate = 0.06m; // 增值税率6% service.BondPayment = 100m; var td = CreateTrade(); var position = CreateFloatPosition(); var preEod = new eod_swap_position { id = 5001, SwapTradeId = SwapTradeId, PositionId = position.id, ValueDate = PreSettleDate, PosiQuantity = 10000m, PosiGrossPrice = 1.002m, PosiNetPrice = 1.005m, UnderlyingCode = "210210.IB", ContractSize = 1m, PositionType = (int)PositionTypeFlag.Long, PosiDirection = 2, PosiDividendSum = 0m, PosiFeePending = 0m, PosiProfitSum = 0m }; var result = service.ExecuteCopyEodPosition(preEod, null, td, TradeDate, PreSettleDate); // TdPosiDividend = 100 / 1.06 × 0.94 = 88.6792... decimal expected = Math.Round(100m / 1.06m * 0.94m, 2); AssertDecimalEqual(expected, result.TdPosiDividend, 0.01m, "增值税调整后分红"); Console.WriteLine($"分红增值税调整: 付息100, 税率6% → TdPosiDividend={result.TdPosiDividend}(期望{expected})✅"); } [TestMethod] public void DF_008_CopyBranch_RealizedPnlIncludesRealizedFee() { var service = new StubEodService { UnderlyingPrice = 1.002m, TaxRate = 0m, BondPayment = 0m }; var preEod = new eod_swap_position { id = 5001, SwapTradeId = SwapTradeId, PositionId = 3001, ValueDate = PreSettleDate, PosiQuantity = 10000m, PosiGrossPrice = 1.002m, PosiNetPrice = 1.005m, UnderlyingCode = "210210.IB", ContractSize = 1m, PositionType = (int)PositionTypeFlag.Long, PosiDirection = (int)SwapDirectionEnum.收取, RealizedMtmPnL = 98000000m, RealizedDividend = 0m, RealizedFee = 100m, RealizedPnl = 98000000m }; var result = service.ExecuteCopyEodPosition(preEod, null, CreateTrade(), TradeDate, PreSettleDate); Assert.AreEqual(98000100m, result.RealizedPnl); } private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "") { Assert.IsTrue(Math.Abs(expected - actual) <= tolerance, $"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}"); } } }