From 4fede8339a7cf775cac0b48c770004364fa6f20a Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 3 Jul 2026 18:12:52 +0800 Subject: [PATCH] =?UTF-8?q?refactor(test):=20SwapDealSettlementTest?= =?UTF-8?q?=E6=8B=86=E5=88=86=E4=B8=BASwapUnwind/SwapIncome+=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E5=85=B1=E4=BA=ABstub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按业务拆分原 SwapDealSettlementTest(8场景)为3个文件,提升可读性: - TestableSwapDealService.cs: 共享stub(7个seam override)+工厂方法(CreateTrade/CreateUnwindData) - SwapUnwindScenarioTest.cs: 平仓全流程6场景(UW_001~006: 全平/部分平/含预付金/含费价重算/审核/提交) - SwapIncomeScenarioTest.cs: 结息2场景(SI_001~002: 正常结息/含预付金返息) 命名对齐 testable 分支风格(SwapUnwindScenarioTest),场景前缀改 UW_/SI_ 更直观。 删除 SwapDealSettlementTest.cs(内容已拆分迁移)。 SwapModule 173测试全绿,无回归。 --- .../SwapModule/SwapDealSettlementTest.cs | 426 ------------------ .../SwapModule/SwapIncomeScenarioTest.cs | 67 +++ .../SwapModule/SwapUnwindScenarioTest.cs | 191 ++++++++ .../SwapModule/TestableSwapDealService.cs | 127 ++++++ 4 files changed, 385 insertions(+), 426 deletions(-) delete mode 100644 UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs create mode 100644 UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs diff --git a/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs b/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs deleted file mode 100644 index a5c4b50a..00000000 --- a/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs +++ /dev/null @@ -1,426 +0,0 @@ -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_007:SwapUnwind 部分平仓 —— HasPartialUnWind=1,TradeStatus 不变 - // ================================================================ - - /// - /// [SD_007] SwapUnwind 部分平仓(借鉴 testable SwapUnwindScenarioTest.Scenario2) - /// ------------------------------------------------------------ - /// 部分平仓(CloseMethod≠全部平仓 且 ClosePercent≠1)走 else 分支: - /// td.HasPartialUnWind = 1,TradeStatus 保持"确认成交"不变 - /// StockEqvNotional/TradeAmount 仍按 CloseNotionalValue/CloseQty 扣减 - /// - [TestMethod] - public void SD_007_SwapUnwind_部分平仓_设HasPartialUnWind且TradeStatus不变() - { - var td = CreateTrade(); - var service = new StubDealService(td); - // 部分平仓:ClosePercent=0.5, CloseQty=5000, CloseNotionalValue=500000 - var unwindData = CreateUnwindData( - swapRealizedPnL: 3000m, swapMarginAmount: 0m, - closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.5m, - closeQty: 5000m, closeNotionalValue: 500000m, positionQty: 10000m); - - service.SwapUnwind(unwindData); - - // 部分平仓:设 HasPartialUnWind=1,TradeStatus 不变 - Assert.AreEqual(1, td.HasPartialUnWind, "部分平仓应设 HasPartialUnWind=1"); - Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变"); - // 持仓扣减:原 StockEqvNotional=1000000 - 500000 = 500000 - Assert.AreEqual(500000.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=500000"); - Assert.AreEqual(5000.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=5000"); - // 资金流水仍有(部分平仓也产生平仓费流水) - Assert.AreEqual(1, service.ClientCashCalls.Count, "部分平仓应1条资金流水"); - Assert.AreEqual(-3000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL"); - Console.WriteLine($"SD_007 通过:HasPartialUnWind={td.HasPartialUnWind},TradeStatus={td.TradeStatus},StockEqvNotional={td.StockEqvNotional} ✅"); - } - - // ================================================================ - // SD_008:SwapUnwind 含预付金 —— 两条资金流水(平仓费+应付预付金) - // ================================================================ - - /// - /// [SD_008] SwapUnwind 含预付金返还(SwapMarginAmount≠0) - /// ------------------------------------------------------------ - /// SwapUnwind 内部:SwapMarginAmount≠0 时追加一条"应付预付金"资金流水。 - /// 验证:2条流水(平仓费 + 应付预付金),金额和操作类型正确。 - /// - [TestMethod] - public void SD_008_SwapUnwind_含预付金_两条资金流水() - { - var td = CreateTrade(); - var service = new StubDealService(td); - var unwindData = CreateUnwindData( - swapRealizedPnL: 5000m, swapMarginAmount: 2000m, - closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, - closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m); - - service.SwapUnwind(unwindData); - - // 2条资金流水:平仓费 + 应付预付金 - Assert.AreEqual(2, service.ClientCashCalls.Count, "含预付金时应2条资金流水"); - Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=平仓费 -SwapRealizedPnL"); - Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action); - Assert.AreEqual(2000.0, service.ClientCashCalls[1].amount, 0.001, "第2条=应付预付金 SwapMarginAmount"); - Assert.AreEqual(ClientCashInCashOut.系统操作_应付预付金, service.ClientCashCalls[1].action); - Console.WriteLine($"SD_008 通过:2条流水,平仓费={service.ClientCashCalls[0].amount},应付预付金={service.ClientCashCalls[1].amount} ✅"); - } - - // ================================================================ - // 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} ✅"); - } - } -} diff --git a/UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs new file mode 100644 index 00000000..5a1d4308 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapIncomeScenarioTest.cs @@ -0,0 +1,67 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 互换结息(SwapIncome)测试 + /// ============================================================================ + /// 借鉴 testable 分支命名,基于当前分支 TestableSwapDealService 共享 stub。 + /// SwapIncome 是写客户资金流水(ClientCashInCashOut)的核心入口之一。 + /// ============================================================================ + [TestClass] + public class SwapIncomeScenarioTest + { + // ================================================================ + // 场景1:SwapIncome 正常结息 —— 资金流水金额正确 + // ================================================================ + + /// + /// SwapIncome 正常结息:SwapRealizedPnL=1000 → 客户资金流水金额=-1000。 + /// 后端 SwapDealService SwapIncome 直接用前端传入的 SwapRealizedPnL 记账。 + /// + [TestMethod] + public void SI_001_SwapIncome_正常结息_资金流水金额正确() + { + var td = SwapDealTestFactory.CreateTrade(); + td.ExerciseDate = new DateTime(2026, 12, 31); // 未到期,不走"已到期"分支 + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.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($"SI_001: 资金流水={service.ClientCashCalls[0].amount}, 事件类型=互换 ✅"); + } + + // ================================================================ + // 场景2:SwapIncome 含预付金返息 —— 两条资金流水 + // ================================================================ + + /// + /// SwapIncome 含预付金返息:SwapRealizedPnL=1000, SwapMarginRebatePnl=200 + /// → 生成2条资金流水(互换 + 预付金返息),金额分别为 -1000、-200。 + /// + [TestMethod] + public void SI_002_SwapIncome_含预付金返息_两条资金流水() + { + var td = SwapDealTestFactory.CreateTrade(); + td.ExerciseDate = new DateTime(2026, 12, 31); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.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条=互换金额"); + Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action); + Assert.AreEqual(-200.0, service.ClientCashCalls[1].amount, 0.001, "第2条=预付金返息"); + Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[1].action); + Console.WriteLine($"SI_002: 互换={service.ClientCashCalls[0].amount}, 预付金返息={service.ClientCashCalls[1].amount} ✅"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs new file mode 100644 index 00000000..103b65f5 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs @@ -0,0 +1,191 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 互换平仓全流程测试(SwapUnwind/ApproveSwapTrade/ApplySwapTrade/DealFloatPosition) + /// ============================================================================ + /// 借鉴 testable 分支 SwapUnwindScenarioTest,基于当前分支 TestableSwapDealService 共享 stub。 + /// 命名规范说明(见《互换价格字段命名规范决策文档》): + /// PosiGrossPrice 现状名,实为"期初全价不含费",规范名 EntryDirtyPrice + /// TradingAmountAvg 现状名,实为"期末全价不含费",规范名 ExitDirtyPrice + /// ============================================================================ + [TestClass] + public class SwapUnwindScenarioTest + { + // ================================================================ + // 场景1:SwapUnwind 全平仓 —— 持仓归零、TradeStatus=已平仓 + // ================================================================ + + [TestMethod] + public void UW_001_SwapUnwind_全平仓_持仓归零且资金流水正确() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 5000m, swapMarginAmount: 0m, + closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, + closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m); + + service.SwapUnwind(unwindData); + + 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=已平仓"); + Assert.AreNotEqual(1, td.HasPartialUnWind, "全平仓不应设 HasPartialUnWind"); + 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($"UW_001: TradeStatus={td.TradeStatus}, StockEqvNotional={td.StockEqvNotional} ✅"); + } + + // ================================================================ + // 场景2:SwapUnwind 部分平仓 —— HasPartialUnWind=1,TradeStatus 不变 + // ================================================================ + + [TestMethod] + public void UW_002_SwapUnwind_部分平仓_设HasPartialUnWind且TradeStatus不变() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 3000m, swapMarginAmount: 0m, + closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.5m, + closeQty: 5000m, closeNotionalValue: 500000m, positionQty: 10000m); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(1, td.HasPartialUnWind, "部分平仓应设 HasPartialUnWind=1"); + Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变"); + Assert.AreEqual(500000.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=500000"); + Assert.AreEqual(5000.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=5000"); + Assert.AreEqual(1, service.ClientCashCalls.Count, "部分平仓应1条资金流水"); + Assert.AreEqual(-3000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL"); + Console.WriteLine($"UW_002: HasPartialUnWind={td.HasPartialUnWind}, TradeStatus={td.TradeStatus} ✅"); + } + + // ================================================================ + // 场景3:SwapUnwind 含预付金 —— 两条资金流水 + // ================================================================ + + [TestMethod] + public void UW_003_SwapUnwind_含预付金_两条资金流水() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData( + swapRealizedPnL: 5000m, swapMarginAmount: 2000m, + closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, + closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(2, service.ClientCashCalls.Count, "含预付金时应2条资金流水"); + Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=平仓费"); + Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action); + Assert.AreEqual(2000.0, service.ClientCashCalls[1].amount, 0.001, "第2条=应付预付金"); + Assert.AreEqual(ClientCashInCashOut.系统操作_应付预付金, service.ClientCashCalls[1].action); + Console.WriteLine($"UW_003: 平仓费={service.ClientCashCalls[0].amount}, 应付预付金={service.ClientCashCalls[1].amount} ✅"); + } + + // ================================================================ + // 场景4:DealFloatPosition 含费价重算(后端唯二真做计算的地方) + // ================================================================ + + /// + /// 平仓事件重算三字段(SwapDealService DealFloatPosition): + /// TradingAmountFeeAvg(ExitDirtyFeePrice) = TradingAmountAvg(ExitDirtyPrice) + Fee/CloseQty × shortRatio + /// TradingAmountNetFeeAvg(ExitCleanFeePrice) = TradingAmountNetAvg(ExitCleanPrice) + Fee/CloseQty × shortRatio + /// TradingAmount = TradingAmountAvg × CloseQty + /// 手算:ExitDirtyPrice=1.02, Fee=50, CloseQty=1000, Long(shortRatio=-1) + /// ExitDirtyFeePrice = 1.02 + 50/1000×(-1) = 0.97 + /// ExitCleanFeePrice = 1.00 + 50/1000×(-1) = 0.95 + /// TradingAmount = 1.02 × 1000 = 1020 + /// + [TestMethod] + public void UW_004_DealFloatPosition_含费价重算正确() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var closeEvent = new swap_flow_event + { + EventType = (int)SwapEventTypeEnum.平仓, + PositionType = (int)PositionTypeFlag.Long, + TradingAmountAvg = 1.02m, // ExitDirtyPrice + TradingAmountNetAvg = 1.00m, // ExitCleanPrice + TradingFeePending = 50m, + }; + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m, closeQty: 1000m); + unwindData.FlowEvents.Add(closeEvent); + + service.SwapUnwind(unwindData); + + Assert.AreEqual(0.97m, closeEvent.TradingAmountFeeAvg, 0.0001m, + $"TradingAmountFeeAvg(ExitDirtyFeePrice)=ExitDirtyPrice+Fee/Qty×(-1)=0.97"); + Assert.AreEqual(0.95m, closeEvent.TradingAmountNetFeeAvg ?? 0m, 0.0001m, + $"TradingAmountNetFeeAvg(ExitCleanFeePrice)=ExitCleanPrice+Fee/Qty×(-1)=0.95"); + Assert.AreEqual(1020m, closeEvent.TradingAmount, 0.0001m, + $"TradingAmount=ExitDirtyPrice×CloseQty=1020"); + Console.WriteLine($"UW_004: ExitDirtyFeePrice={closeEvent.TradingAmountFeeAvg}, TradingAmount={closeEvent.TradingAmount} ✅"); + } + + // ================================================================ + // 场景5:ApproveSwapTrade 审核通过全平仓 —— 反序列化事件并记账 + // ================================================================ + + [TestMethod] + public void UW_005_ApproveSwapTrade_全平仓审核_反序列化事件并记账() + { + var td = SwapDealTestFactory.CreateTrade(); + td.ExerciseDate = new DateTime(2026, 12, 31); + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 8000m, + closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m, + closeQty: 10000m, closeNotionalValue: 1000000m); + var swapEvent = new swap_event + { + id = 1, SwapTradeId = SwapDealTestFactory.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 TestableSwapDealService(td, + swapEvents: new Dictionary { [(int)SwapEventTypeEnum.平仓] = swapEvent }, + flowEventsByEventId: flowEvents); + + service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓); + + 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($"UW_005: 反序列化SwapRealizedPnL=8000, 资金流水={service.ClientCashCalls[0].amount}, TradeStatus={td.TradeStatus} ✅"); + } + + // ================================================================ + // 场景6:ApplySwapTrade 提交审核 —— 前置校验与保存事件 + // ================================================================ + + [TestMethod] + public void UW_006_ApplySwapTrade_提交审核_前置校验与保存事件() + { + var td = SwapDealTestFactory.CreateTrade(); + var service = new TestableSwapDealService(td); + var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m); + unwindData.SwapCloseAmount = 6000m; + + service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓); + + Assert.AreEqual(1, service.CloseReCheckCallCount, "应调用 CloseReCheckSetTrade 1次"); + Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次"); + Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓"); + Assert.AreEqual(6000m, service.SaveSwapDealCalls[0].data.SwapRealizedPnL, 0.001m, + "SwapRealizedPnL 应=SwapCloseAmount(6000)"); + Console.WriteLine($"UW_006: CloseReCheck={service.CloseReCheckCallCount}次, SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs new file mode 100644 index 00000000..8b404f7e --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/TestableSwapDealService.cs @@ -0,0 +1,127 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// SwapDealService 的可测试化子类(共享 stub)。 + /// 继承 SwapDealService,override seam 把 DB/事务/外部服务替换为内存收集器。 + /// 被 SwapUnwindScenarioTest / SwapIncomeScenarioTest 共用,避免重复。 + /// + public class TestableSwapDealService : SwapDealService + { + private readonly trade _trade; + private readonly Dictionary _swapEvents; + private readonly Dictionary> _flowEventsByEventId; + + /// 捕获 AddClientCash 的每次调用(金额, 操作, 日期) + public List<(double amount, string action, DateTime date)> ClientCashCalls { get; } = new(); + + /// 捕获 SaveSwapDeal 的每次调用(unwindData, eventType, clientCashId) + public List<(UnwindData data, int eventType, int clientCashId)> SaveSwapDealCalls { get; } = new(); + + public int SaveAllChangesCount; + public int CloseReCheckCallCount; + + public TestableSwapDealService(trade td, + Dictionary swapEvents = null, + Dictionary> flowEventsByEventId = null) + : base(new OptUserInfo(0, nameof(TestableSwapDealService), 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() { } // 空操作 + } + + /// + /// SwapDealService 测试的共享工厂方法(TestableSwapDealService + UnwindData 构造)。 + /// 被 SwapUnwindScenarioTest / SwapIncomeScenarioTest 共用。 + /// + public static class SwapDealTestFactory + { + public const int SwapTradeId = 7700; + public static readonly DateTime ValueDate = new(2026, 6, 15); + public static readonly DateTime UnwindDate = new(2026, 6, 16); + + public 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(金额由前端算好传入,后端直接用) + public 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) + }; + } + + public 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}"); + } + } +}