using Newtonsoft.Json; using YLErp.DBModels; using YLErp.DBModels.Enums; namespace YLErp.Modules.SwapModule { /// /// SwapDealService 手动结算(SwapIncome/SwapUnwind)内存单元测试 /// ============================================================================ /// 背景:SwapIncome/SwapUnwind 是写客户资金流水(ClientCashInCashOut)的核心入口, /// 此前零单元测试(仅 DBRecording,CI 不跑)。本测试通过 7 个 virtual seam /// 把 DB/事务/外部服务打桩,在纯内存下验证控制流、资金流水金额、持仓状态变更。 /// /// 命名规范说明(见《互换价格字段命名规范决策文档》): /// 本测试引用现状字段(如 PosiGrossPrice/PosiNetPrice)时加对照注释, /// 标明其真实含义与规范名,让测试可读、可作规范示范。 /// - PosiGrossPrice 现状名,实为"期初全价不含费",规范名 EntryDirtyPrice /// - PosiNetPrice 现状名,实为"期初全价含费"(非净价!),规范名 EntryDirtyFeePrice /// ============================================================================ [TestClass] public class SwapDealSettlementTest { private const int SwapTradeId = 7700; private static readonly DateTime ValueDate = new(2026, 6, 15); private static readonly DateTime UnwindDate = new(2026, 6, 16); #region Stub /// /// 继承 SwapDealService,override 7 个 seam,把 DB/事务/外部服务替换为内存收集器。 /// 生产路径零改动(seam 生产实现 = 原逻辑),测试可纯内存运行。 /// private sealed class StubDealService : SwapDealService { private readonly trade _trade; private readonly Dictionary _swapEvents; private readonly Dictionary> _flowEventsByEventId; public List<(double amount, string action, DateTime date)> ClientCashCalls = new(); public List<(UnwindData data, int eventType, int clientCashId)> SaveSwapDealCalls = new(); public int SaveAllChangesCount; public int CloseReCheckCallCount; public StubDealService(trade td, Dictionary swapEvents = null, Dictionary> flowEventsByEventId = null) : base(new OptUserInfo(0, nameof(SwapDealSettlementTest), OptUserFrom.UnitTest)) { _trade = td; _swapEvents = swapEvents ?? new Dictionary(); _flowEventsByEventId = flowEventsByEventId ?? new Dictionary>(); } protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null; protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate) { ClientCashCalls.Add((amount, action, valueDate)); return ClientCashCalls.Count; // 返回自增 id } // 整体 override SaveSwapDeal:收集入参,规避内部 new SwapEventService 连库 protected override long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false) { SaveSwapDealCalls.Add((unwindData, eventType, clientCashId)); return SaveSwapDealCalls.Count; // 返回自增 eventId } // ApproveSwapTrade 查待审核事件:从内存字典取(key=eventType) protected override swap_event FindSwapEvent(int tradeId, int eventType) { return _swapEvents.TryGetValue(eventType, out var evt) ? evt : null; } // ApproveSwapTrade 查事件关联流水:从内存字典取 protected override List FindFlowEventsByEventId(long eventId) { return _flowEventsByEventId.TryGetValue(eventId, out var list) ? list : new List(); } // ApplySwapTrade 的前置校验:计数,不实际执行 protected override void CloseReCheckSetTrade(int swapTradeId, bool isSwap, bool needCheck) { CloseReCheckCallCount++; } protected override void SaveAllChanges() { SaveAllChangesCount++; } protected override void ExecuteInTransaction(Action action) => action(); // 不包事务,直接执行 protected override void CallSaveSwapTradeClientCash(trade td, DateTime valueDate) { } // 空操作 protected override void TriggerRealtimeSwapPosition() { } // 空操作 } #endregion #region 数据构建 private static trade CreateTrade() { return new trade { id = SwapTradeId, TradeNumber = "UT-SD-001", ClientId = 888888, TradeType = "收益互换", StartDate = new DateTime(2026, 1, 5), ExerciseDate = new DateTime(2026, 6, 14), // 已到期边界(SwapIncome 判断用) TradeStatus = "确认成交", ValidState = "Valid", Notional = 1000000, StockEqvNotional = 1000000, TradeAmount = 10000 }; } /// 构造结息/平仓的 UnwindData(金额由前端算好传入,后端直接用) private static UnwindData CreateUnwindData(decimal swapRealizedPnL, decimal swapMarginRebatePnl = 0m, decimal swapMarginAmount = 0m, int closeMethod = 0, decimal closePercent = 0m, decimal closeQty = 0m, decimal closeNotionalValue = 0m, decimal positionQty = 0m) { return new UnwindData { SwapTradeId = SwapTradeId, SwapRealizedPnL = swapRealizedPnL, SwapMarginRebatePnl = swapMarginRebatePnl, SwapMarginAmount = swapMarginAmount, SwapCloseAmount = swapRealizedPnL, CloseMethod = closeMethod, ClosePercent = closePercent, CloseQty = closeQty, CloseNotionalValue = closeNotionalValue, PositionQty = positionQty, ValueDate = ValueDate, UnwindDate = UnwindDate, StartDate = new DateTime(2026, 1, 5) }; } #endregion // ================================================================ // SD_001:SwapIncome 正常结息 —— 验证资金流水金额正确 // ================================================================ /// /// [SD_001] SwapIncome 正常结息:SwapRealizedPnL=1000 → 客户资金流水金额=-1000 /// ------------------------------------------------------------ /// 后端 SwapDealService.cs:1553 直接用前端传入的 SwapRealizedPnL 记账: /// AddClientCash(td, -SwapRealizedPnL, 系统操作_互换, ValueDate) /// 本测试锁定:资金流水金额 = -SwapRealizedPnL,事件类型 = 互换(3)。 /// [TestMethod] public void SD_001_SwapIncome_正常结息_资金流水金额正确() { var td = CreateTrade(); td.ExerciseDate = new DateTime(2026, 12, 31); // 未到期,不走"已到期"分支 var service = new StubDealService(td); var unwindData = CreateUnwindData(swapRealizedPnL: 1000m); service.SwapIncome(unwindData); Assert.AreEqual(1, service.ClientCashCalls.Count, "应生成1条资金流水(互换)"); Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水金额 = -SwapRealizedPnL"); Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action, "操作类型=系统操作_互换"); Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次"); Assert.AreEqual((int)SwapEventTypeEnum.互换, service.SaveSwapDealCalls[0].eventType, "事件类型=互换(3)"); Console.WriteLine($"SD_001 通过:资金流水金额={service.ClientCashCalls[0].amount},事件类型=互换 ✅"); } // ================================================================ // SD_002:SwapIncome 含预付金返息 —— 两条资金流水 // ================================================================ /// /// [SD_002] SwapIncome 含预付金返息:SwapRealizedPnL=1000, SwapMarginRebatePnl=200 /// → 生成2条资金流水(互换 + 预付金返息),金额分别为 -1000、-200 /// 后端 SwapDealService.cs:1556 条件:SwapMarginRebatePnl != 0 时追加预付金返息流水。 /// [TestMethod] public void SD_002_SwapIncome_含预付金返息_两条资金流水() { var td = CreateTrade(); td.ExerciseDate = new DateTime(2026, 12, 31); var service = new StubDealService(td); var unwindData = CreateUnwindData(swapRealizedPnL: 1000m, swapMarginRebatePnl: 200m); service.SwapIncome(unwindData); Assert.AreEqual(2, service.ClientCashCalls.Count, "应生成2条资金流水(互换+预付金返息)"); Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=互换金额 -SwapRealizedPnL"); Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action); Assert.AreEqual(-200.0, service.ClientCashCalls[1].amount, 0.001, "第2条=预付金返息 -SwapMarginRebatePnl"); Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[1].action); Console.WriteLine($"SD_002 通过:2条资金流水,互换={service.ClientCashCalls[0].amount},预付金返息={service.ClientCashCalls[1].amount} ✅"); } // ================================================================ // SD_003:SwapUnwind 全平仓 —— 持仓归零、资金流水、状态变更 // ================================================================ /// /// [SD_003] SwapUnwind 全平仓:ClosePercent=1 → TradeStatus=已平仓、持仓扣减、资金流水正确 /// 后端 SwapDealService.cs SwapUnwind:全平时 TradeStatus=已平仓,StockEqvNotional/TradeAmount 扣减。 /// [TestMethod] public void SD_003_SwapUnwind_正常平仓_资金流水与持仓状态正确() { var td = CreateTrade(); var service = new StubDealService(td); // 全平:ClosePercent=1, CloseQty=10000, CloseNotionalValue=1000000 var unwindData = CreateUnwindData( swapRealizedPnL: 5000m, swapMarginAmount: 0m, closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m); service.SwapUnwind(unwindData); // 资金流水:平仓费 = -SwapRealizedPnL Assert.AreEqual(1, service.ClientCashCalls.Count, "全平无预付金时应1条资金流水"); Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL"); Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action); // 持仓状态 Assert.AreEqual("已平仓", td.TradeStatus, "全平仓 TradeStatus=已平仓"); // 全平仓走"已平仓"分支,不设 HasPartialUnWind(仅部分平仓才设=1) Assert.AreNotEqual(1, td.HasPartialUnWind, "全平仓不应设 HasPartialUnWind(仅部分平仓设=1)"); // 持仓扣减:原 StockEqvNotional=1000000 - CloseNotionalValue=1000000 = 0 Assert.AreEqual(0.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=0"); Assert.AreEqual(0.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=0"); // 事件类型 Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓(2)"); Console.WriteLine($"SD_003 通过:TradeStatus={td.TradeStatus},StockEqvNotional={td.StockEqvNotional} ✅"); } // ================================================================ // SD_004:DealFloatPosition 含费价重算正确(后端唯二真做计算的地方) // ================================================================ /// /// [SD_004] DealFloatPosition 含费价重算(SwapDealService.cs:1713-1725) /// ------------------------------------------------------------ /// 平仓事件重算三个字段(规范语义,见命名文档): /// TradingAmountFeeAvg(ExitDirtyFeePrice)= TradingAmountAvg(ExitDirtyPrice) + TradingFeePending/CloseQty × shortRatio /// TradingAmountNetFeeAvg(ExitCleanFeePrice)= TradingAmountNetAvg(ExitCleanPrice) + TradingFeePending/CloseQty × shortRatio /// TradingAmount = TradingAmountAvg × CloseQty /// 这是后端少数真正做计算(而非透传前端值)的地方,需锁住。 /// /// 手算:ExitDirtyPrice=1.02, TradingFeePending=50, CloseQty=1000, Long(多头,shortRatio=-1) /// ExitDirtyFeePrice = 1.02 + 50/1000 × (-1) = 1.02 - 0.05 = 0.97 /// ExitCleanFeePrice = 1.00 + 50/1000 × (-1) = 1.00 - 0.05 = 0.95 /// TradingAmount = 1.02 × 1000 = 1020 /// [TestMethod] public void SD_004_DealFloatPosition_含费价重算正确() { var td = CreateTrade(); var service = new StubDealService(td); // 构造平仓事件(PositionType>0 触发重算) var closeEvent = new swap_flow_event { EventType = (int)SwapEventTypeEnum.平仓, PositionType = (int)PositionTypeFlag.Long, // 多头,shortRatio=-1 // TradingAmountAvg 现状名,实为"期末全价不含费",规范名 ExitDirtyPrice TradingAmountAvg = 1.02m, // TradingAmountNetAvg 现状名,实为"期末净价不含费",规范名 ExitCleanPrice TradingAmountNetAvg = 1.00m, TradingFeePending = 50m, }; var unwindData = CreateUnwindData(swapRealizedPnL: 0m, closeQty: 1000m); unwindData.FlowEvents.Add(closeEvent); service.SwapUnwind(unwindData); // ExitDirtyFeePrice(TradingAmountFeeAvg)= 1.02 + 50/1000×(-1) = 0.97 Assert.AreEqual(0.97m, closeEvent.TradingAmountFeeAvg, 0.0001m, $"TradingAmountFeeAvg(ExitDirtyFeePrice) 应=ExitDirtyPrice(1.02)+Fee/CloseQty×(-1)=0.97,实际={closeEvent.TradingAmountFeeAvg}"); // ExitCleanFeePrice(TradingAmountNetFeeAvg)= 1.00 + 50/1000×(-1) = 0.95 Assert.AreEqual(0.95m, closeEvent.TradingAmountNetFeeAvg ?? 0m, 0.0001m, $"TradingAmountNetFeeAvg(ExitCleanFeePrice) 应=ExitCleanPrice(1.00)+Fee/CloseQty×(-1)=0.95,实际={closeEvent.TradingAmountNetFeeAvg}"); // TradingAmount = ExitDirtyPrice × CloseQty = 1.02 × 1000 = 1020 Assert.AreEqual(1020m, closeEvent.TradingAmount, 0.0001m, $"TradingAmount 应=ExitDirtyPrice(1.02)×CloseQty(1000)=1020,实际={closeEvent.TradingAmount}"); Console.WriteLine($"SD_004 通过:ExitDirtyFeePrice={closeEvent.TradingAmountFeeAvg},ExitCleanFeePrice={closeEvent.TradingAmountNetFeeAvg},TradingAmount={closeEvent.TradingAmount} ✅"); } // ================================================================ // SD_005:ApproveSwapTrade 审核通过 —— 反序列化事件、资金流水、持仓状态 // ================================================================ /// /// [SD_005] ApproveSwapTrade 审核通过全部平仓 /// ------------------------------------------------------------ /// 后端 SwapDealService.ApproveSwapTrade:从 swap_event.EventData 反序列化 UnwindData, /// 据此生成资金流水 + 更新持仓状态。 /// 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario4,验证: /// - SwapRealizedPnL 从事件反序列化正确(EventData JSON) /// - 资金流水金额 = -SwapRealizedPnL /// - 全平仓 → TradeStatus=已平仓 /// [TestMethod] public void SD_005_ApproveSwapTrade_全平仓审核_反序列化事件并记账() { var td = CreateTrade(); // 构造待审核事件:EventData 里序列化了 UnwindData(含 SwapRealizedPnL=8000) var unwindData = CreateUnwindData(swapRealizedPnL: 8000m, closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, closeQty: 10000m, closeNotionalValue: 1000000m); var swapEvent = new swap_event { id = 1, SwapTradeId = SwapTradeId, EventType = (int)SwapEventTypeEnum.平仓, Invalid = false, EventData = JsonConvert.SerializeObject(unwindData) }; var flowEvents = new Dictionary> { [1] = new List { new swap_flow_event { id = 1, EventId = 1, PositionId = 1 } } }; var service = new StubDealService(td, swapEvents: new Dictionary { [(int)SwapEventTypeEnum.平仓] = swapEvent }, flowEventsByEventId: flowEvents); service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓); // 资金流水:从反序列化的 SwapRealizedPnL(8000) 记账 → -8000 Assert.AreEqual(1, service.ClientCashCalls.Count, "全平仓无预付金时应1条资金流水"); Assert.AreEqual(-8000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-反序列化的SwapRealizedPnL"); // 持仓状态 Assert.AreEqual("已平仓", td.TradeStatus, "审核全平仓 TradeStatus=已平仓"); Console.WriteLine($"SD_005 通过:审核反序列化 SwapRealizedPnL=8000,资金流水={service.ClientCashCalls[0].amount},TradeStatus={td.TradeStatus} ✅"); } // ================================================================ // SD_006:ApplySwapTrade 提交审核 —— 前置校验 + 保存事件 // ================================================================ /// /// [SD_006] ApplySwapTrade 提交审核 /// ------------------------------------------------------------ /// 后端 SwapDealService.ApplySwapTrade:调 CloseReCheckSetTrade 前置校验 + SaveSwapDeal(approve=true)。 /// 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario5,验证: /// - CloseReCheckSetTrade 被调用1次 /// - SaveSwapDeal 以 approve=true 调用(事件类型正确) /// - SwapRealizedPnL = SwapCloseAmount(ApplySwapTrade 内部赋值) /// [TestMethod] public void SD_006_ApplySwapTrade_提交审核_前置校验与保存事件() { var td = CreateTrade(); var service = new StubDealService(td); // 前端提交时 SwapCloseAmount=6000(前端算好的总额),SwapRealizedPnL 初始可能为0 var unwindData = CreateUnwindData(swapRealizedPnL: 0m); unwindData.SwapCloseAmount = 6000m; // 模拟前端传入的平仓总额 service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓); // 前置校验被调用 Assert.AreEqual(1, service.CloseReCheckCallCount, "应调用 CloseReCheckSetTrade 1次"); // SaveSwapDeal 以 approve=true 调用 Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次"); Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓"); // SwapRealizedPnL 应被赋值为 SwapCloseAmount(ApplySwapTrade 内部 cs:1631) Assert.AreEqual(6000m, service.SaveSwapDealCalls[0].data.SwapRealizedPnL, 0.001m, "SwapRealizedPnL 应=SwapCloseAmount(6000)"); Console.WriteLine($"SD_006 通过:CloseReCheck 调用{service.CloseReCheckCallCount}次,SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅"); } } }