diff --git a/UnitTestProject/Modules/SwapModule/MergePageEventScenarioTest.cs b/UnitTestProject/Modules/SwapModule/MergePageEventScenarioTest.cs new file mode 100644 index 00000000..c77a5380 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/MergePageEventScenarioTest.cs @@ -0,0 +1,288 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Linq; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Model; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 用构造数据覆盖 MergePageEvent 全部 6 种场景 + /// 不连数据库,纯内存,秒级运行 + /// + [TestClass] + public class MergePageEventScenarioTest + { + private const int TradeId = 9001; + private const string TradeNumber = "TEST-IS-202504240001"; + private const string UnderlyingCode = "220205.IB"; + private const long PositionId = 50001; + + #region 场景1:单条流水 + 无持仓 → 纯开仓 + + [TestMethod] + public void Scenario1_SingleMerge_NoPosition_ShouldOpen() + { + var service = CreateService(positions: new List()); + var merges = new List + { + CreateMerge(BsType: 1, Qty: 100000, AvgPrice: 1.0022m, Fee: 0, FeePending: 2000) + }; + + var result = service.ExecuteMergePageEvent(TradeId, merges, new DateTime(2025, 4, 24)); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual((int)SwapFlowEventTypeEnum.开仓, result[0].EventType); + Assert.AreEqual(1, result[0].PositionType); // 多头 + Assert.AreEqual(100000, result[0].Quantity); + Assert.AreEqual(1.0022m, result[0].TradingAmountAvg); + Assert.AreEqual(0, result[0].MarkClosePnl); // 开仓无平仓盈亏 + } + + #endregion + + #region 场景2:单条流水 + 同向持仓 → 追加开仓 + + [TestMethod] + public void Scenario2_SingleMerge_SameDirectionPosition_ShouldOpen() + { + var positions = new List + { + CreatePosition(PositionType: 1, Qty: 50000, GrossPrice: 0.99m) + }; + var service = CreateService(positions); + var merges = new List + { + CreateMerge(BsType: 1, Qty: 30000, AvgPrice: 1.005m) + }; + + var result = service.ExecuteMergePageEvent(TradeId, merges, new DateTime(2025, 4, 24)); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual((int)SwapFlowEventTypeEnum.开仓, result[0].EventType); + Assert.AreEqual(1, result[0].PositionType); // 同向多头 + Assert.AreEqual(30000, result[0].Quantity); // 新开仓数量 + } + + #endregion + + #region 场景3:单条流水 + 反向持仓(全平) → 纯平仓 + + [TestMethod] + public void Scenario3_SingleMerge_OppositeFullClose_ShouldCloseOnly() + { + var positions = new List + { + CreatePosition(PositionType: 2, Qty: 100000, GrossPrice: 0.98m) + }; + var service = CreateService(positions); + // 买入100000,但持仓是空头100000 → 全部平仓 + var merges = new List + { + CreateMerge(BsType: 1, Qty: 100000, AvgPrice: 1.01m) + }; + + var result = service.ExecuteMergePageEvent(TradeId, merges, new DateTime(2025, 4, 24)); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual((int)SwapFlowEventTypeEnum.平仓, result[0].EventType); + Assert.AreEqual(2, result[0].PositionType); // 平空头 + Assert.AreEqual(100000, result[0].Quantity); + + // 平仓盈亏 = (平仓均价 - 持仓期初价) * 平仓数量 * 合约乘数 + var expectedPnl = (1.01m - 0.98m) * 100000 * 1; + AssertDecimalEqual(expectedPnl, result[0].MarkClosePnl, 0.01m); + } + + #endregion + + #region 场景4:单条流水 + 反向持仓(部分平) → 平仓+开仓 + + [TestMethod] + public void Scenario4_SingleMerge_OppositePartialClose_ShouldCloseAndOpen() + { + var positions = new List + { + CreatePosition(PositionType: 2, Qty: 30000, GrossPrice: 0.98m) + }; + var service = CreateService(positions); + // 买入100000,持仓空头30000 → 先平30000,再开70000 + var merges = new List + { + CreateMerge(BsType: 1, Qty: 100000, AvgPrice: 1.01m) + }; + + var result = service.ExecuteMergePageEvent(TradeId, merges, new DateTime(2025, 4, 24)); + + Assert.AreEqual(2, result.Count); + + // 第一个:平仓 + Assert.AreEqual((int)SwapFlowEventTypeEnum.平仓, result[0].EventType); + Assert.AreEqual(2, result[0].PositionType); + Assert.AreEqual(30000, result[0].Quantity); + + var expectedClosePnl = (1.01m - 0.98m) * 30000 * 1; + AssertDecimalEqual(expectedClosePnl, result[0].MarkClosePnl, 0.01m); + + // 第二个:开仓 + Assert.AreEqual((int)SwapFlowEventTypeEnum.开仓, result[1].EventType); + Assert.AreEqual(1, result[1].PositionType); // 剩余方向=买 + Assert.AreEqual(70000, result[1].Quantity); // 100000 - 30000 + } + + #endregion + + #region 场景5:两条流水 + 无持仓 → 一开一平 + + [TestMethod] + public void Scenario5_TwoMerges_NoPosition_ShouldOpenThenClose() + { + var service = CreateService(positions: new List()); + var merges = new List + { + CreateMerge(BsType: 1, Qty: 100000, AvgPrice: 1.00m), // 买 10万 + CreateMerge(BsType: 2, Qty: 30000, AvgPrice: 1.01m) // 卖 3万 + }; + + var result = service.ExecuteMergePageEvent(TradeId, merges, new DateTime(2025, 4, 24)); + + Assert.AreEqual(2, result.Count); + + // 第一个事件:开仓(大的那条) + var openEvt = result.First(x => x.EventType == (int)SwapFlowEventTypeEnum.开仓); + Assert.AreEqual(1, openEvt.PositionType); + Assert.AreEqual(100000, openEvt.Quantity); + + // 第二个事件:平仓(小的那条平大的) + var closeEvt = result.First(x => x.EventType == (int)SwapFlowEventTypeEnum.平仓); + Assert.AreEqual(1, closeEvt.PositionType); // 平的是多头的方向 + Assert.AreEqual(30000, closeEvt.Quantity); + + // 平仓盈亏 = (卖均价 - 买均价) * 平仓数量 * 合约乘数 + var expectedPnl = (1.01m - 1.00m) * 30000 * 1; + AssertDecimalEqual(expectedPnl, closeEvt.MarkClosePnl, 0.01m); + } + + #endregion + + #region 场景6:两条流水 + 有持仓 → 复杂组合 + + [TestMethod] + public void Scenario6_TwoMerges_HasPosition_ShouldCloseThenOpen() + { + var positions = new List + { + CreatePosition(PositionType: 2, Qty: 30000, GrossPrice: 0.98m) // 空头持仓 + }; + var service = CreateService(positions); + // 买10万 + 卖5万,持仓空头3万 + var merges = new List + { + CreateMerge(BsType: 1, Qty: 100000, AvgPrice: 1.00m), // 买(反向) + CreateMerge(BsType: 2, Qty: 50000, AvgPrice: 1.01m) // 卖(同向) + }; + + var result = service.ExecuteMergePageEvent(TradeId, merges, new DateTime(2025, 4, 24)); + + // 至少有平仓事件(买的10万 vs 空头3万) + Assert.IsTrue(result.Count >= 2, $"Expected at least 2 events, got {result.Count}"); + + // 第一个事件应该是平仓(反向流水平空头持仓) + Assert.AreEqual((int)SwapFlowEventTypeEnum.平仓, result[0].EventType); + Assert.AreEqual(2, result[0].PositionType); // 平空头 + Assert.AreEqual(30000, result[0].Quantity); + + // 后续应有开仓事件(100000-30000=70000剩余,再和卖5万处理) + var openEvents = result.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.开仓).ToList(); + Assert.IsTrue(openEvents.Count >= 1, "Should have at least 1 open event"); + } + + #endregion + + #region 辅助方法 + + private TestableSwapFlowEventService CreateService(List positions) + { + var user = new OptUserInfo(1, "Test", OptUserFrom.UnitTest); + var trade = new trade + { + id = TradeId, + TradeNumber = TradeNumber, + TradeDate = new DateTime(2025, 4, 24), + ExerciseDate = new DateTime(2025, 6, 20) + }; + var extend = new trade_extend { TradeId = TradeId }; + extend.ExtendJson = Newtonsoft.Json.JsonConvert.SerializeObject(new TradeExtendJson + { + Direction = 2, // 支付 + SettlementRules = 1 // T+1 + }); + + var underlyings = new Dictionary + { + [UnderlyingCode] = new underlying_manager + { + UnderlyingCode = UnderlyingCode, + UnderlyingInstrumentType = "TBonds" + } + }; + + return new TestableSwapFlowEventService( + user, trade, extend, positions, underlyings, + nextBusinessDay: d => d.AddDays(1), + positionId: PositionId + ); + } + + private swap_flow_merge CreateMerge(int BsType, decimal Qty, decimal AvgPrice, decimal Fee = 0, decimal FeePending = 0) + { + return new swap_flow_merge + { + SwapTradeId = TradeId, + SwapTradeNo = TradeNumber, + UnderlyingCode = UnderlyingCode, + OccurTime = new DateTime(2025, 4, 24), + BsType = BsType, + TradingQty = BsType == 1 ? Qty : -Qty, + TradingAmount = Math.Abs(Qty) * AvgPrice, + TradingAmountAvg = AvgPrice, + TradingAmountFeeAvg = AvgPrice, + TradingFee = Fee, + TradingFeePending = FeePending, + ContractSize = 1, + ClientId = 10, + DataState = 1 + }; + } + + private swap_position CreatePosition(int PositionType, decimal Qty, decimal GrossPrice) + { + return new swap_position + { + PositionId = PositionId, + SwapTradeId = TradeId, + UnderlyingCode = UnderlyingCode, + PositionType = PositionType, + PosiDirection = 2, // 支付 + PosiQuantity = Qty, + PosiGrossPrice = GrossPrice, + PosiNetPrice = GrossPrice, + ContractSize = 1, + IsInitial = false, + Invalid = false, + PosiTradingFee = 0 + }; + } + + private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance) + { + var diff = Math.Abs(expected - actual); + Assert.IsTrue(diff <= tolerance, + $"Expected {expected}, got {actual}, diff {diff} (tol {tolerance})"); + } + + #endregion + } +} diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapFlowEventService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapFlowEventService.cs new file mode 100644 index 00000000..6ff0c789 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/TestableSwapFlowEventService.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using YLErp.DBModels; +using YLErp.DBModels.Enums; +using YLErp.Model; +using YLErp.Modules.SwapModule; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 可测试的 SwapFlowEventService 子类 + /// override 所有外部依赖方法,用内存数据替代数据库和静态调用 + /// + public class TestableSwapFlowEventService : SwapFlowEventService + { + private readonly trade _trade; + private readonly trade_extend _tradeExtend; + private readonly List _positions; + private readonly Dictionary _underlyings; + private readonly Func _nextBusinessDay; + private readonly long _positionId; + + public List PersistedEvents { get; } = new List(); + + public TestableSwapFlowEventService( + OptUserInfo optUser, + trade trade, + trade_extend tradeExtend, + List positions, + Dictionary underlyings, + Func nextBusinessDay, + long positionId = 999999 + ) : base(optUser) + { + _trade = trade; + _tradeExtend = tradeExtend; + _positions = positions ?? new List(); + _underlyings = underlyings ?? new Dictionary(); + _nextBusinessDay = nextBusinessDay ?? (d => d.AddDays(1)); + _positionId = positionId; + } + + protected override trade FindTrade(int swapTradeId) => _trade; + + protected override trade_extend FindTradeExtend(int swapTradeId) => _tradeExtend; + + protected override List FindPositions(int swapTradeId) => _positions; + + protected override List FindAndInvalidateFutureEvents(int swapTradeId, DateTime tradeDate) + { + // 测试环境:不需要废弃历史事件 + return new List(); + } + + protected override underlying_manager GetUnderlying(string underlyingCode) + { + return _underlyings.TryGetValue(underlyingCode, out var ul) ? ul : new underlying_manager { UnderlyingCode = underlyingCode }; + } + + protected override DateTime GetNextBusinessDay(DateTime date) => _nextBusinessDay(date); + + protected override long ResolvePositionId(swap_flow_merge merge, DateTime maturityDate, int direction, string tradeNumber) => _positionId; + + protected override void PersistEvents(List events) + { + // 不写 DB,收集到列表供验证 + PersistedEvents.AddRange(events); + } + + protected override IDisposable BeginTransaction() => new NoopDisposable(); + + protected override void CommitTransaction(IDisposable transaction) { } + + protected override void RollbackTransaction(IDisposable transaction) { } + + /// 公开调用 protected 的 MergePageEvent,供测试使用 + public List ExecuteMergePageEvent(int swapTradeId, List flowMergeList, DateTime tradeDate, bool needTrans = false) + { + return MergePageEvent(swapTradeId, flowMergeList, tradeDate, needTrans); + } + + public override void UpdateDbOption(DBModelBaseV2 dBModel) + { + // 测试环境不设 Opt 信息,避免依赖 UserInfo + } + + private class NoopDisposable : IDisposable + { + public void Dispose() { } + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs b/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs index b6d9fec1..b5d41579 100644 --- a/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapFlowEventService.cs @@ -31,6 +31,53 @@ namespace YLErp.Modules.SwapModule } + #region 可测试化接缝(Seams)——借鉴 refactor-swap-event-testable 分支,override 可在测试中替换 DB/外部调用,生产代码行为不变 + + protected virtual trade FindTrade(int swapTradeId) + => DbContext.trade.Find(swapTradeId); + + protected virtual trade_extend FindTradeExtend(int swapTradeId) + => DbContext.trade_extend.First(x => x.TradeId == swapTradeId); + + protected virtual List FindPositions(int swapTradeId) + => DbContext.swap_position.Where(x => x.SwapTradeId == swapTradeId && !x.IsInitial && !x.Invalid).AsNoTracking().ToList(); + + protected virtual List FindAndInvalidateFutureEvents(int swapTradeId, DateTime tradeDate) + { + var olds = DbContext.swap_flow_event.Where(x => x.EventDate > tradeDate && x.SwapTradeId == swapTradeId && x.DataState > 0); + olds.ForEach(x => x.DataState = (int)SwapFlowDateStateEnum.废弃); + return olds.ToList(); + } + + protected virtual underlying_manager GetUnderlying(string underlyingCode) + => DataCacheProvider.GetUnderlyingDataSource().GetData(underlyingCode); + + protected virtual DateTime GetNextBusinessDay(DateTime date) + => QdpCalendarHelper.GetNonHoliday(date); + + protected virtual long ResolvePositionId(swap_flow_merge merge, DateTime maturityDate, int direction, string tradeNumber) + => GetMaxPositionId(merge, maturityDate, direction, tradeNumber); + + protected virtual void PersistEvents(List events) + { + foreach (var evt in events) + { + DbContext.swap_flow_event.Add(evt); + } + DbContext.SaveChanges(); + } + + protected virtual IDisposable BeginTransaction() + => DbContext.Database.BeginTransaction(); + + protected virtual void CommitTransaction(IDisposable transaction) + => (transaction as Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction)?.Commit(); + + protected virtual void RollbackTransaction(IDisposable transaction) + => (transaction as Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction)?.Rollback(); + + #endregion + /// /// 互换流水开平仓事件 /// @@ -70,7 +117,7 @@ namespace YLErp.Modules.SwapModule /// 分页处理互换流水开平仓事件,暂时只按加权平均处理 /// /// - private List MergePageEvent(int swapTradeId, List flowMergeList, DateTime tradeDate, bool needTrans = true) + protected virtual List MergePageEvent(int swapTradeId, List flowMergeList, DateTime tradeDate, bool needTrans = true) { List flowEvents = new List(); //按照同一互换编码、标的、买卖方向排序,一条买,一条卖//会存在买卖不在同一页 @@ -79,26 +126,22 @@ namespace YLErp.Modules.SwapModule { return flowEvents; } - var trade = DbContext.trade.Find(swapTradeId); - var tradeExtend = DbContext.trade_extend.First(x => x.TradeId == swapTradeId); - var trans = needTrans ? DbContext.Database.BeginTransaction() : null; + var trade = FindTrade(swapTradeId); + var tradeExtend = FindTradeExtend(swapTradeId); + var trans = needTrans ? BeginTransaction() : null; try { - var eodPositions = DbContext.swap_position.Where(x => x.SwapTradeId == swapTradeId && !x.IsInitial && !x.Invalid).AsNoTracking().ToList();//上一日终持仓信息 - var swapFlowEventOlds = DbContext.swap_flow_event.Where(x => x.EventDate > tradeDate && x.SwapTradeId == swapTradeId && x.DataState > 0);//废弃当前清算日期及之后的开平仓事件 - swapFlowEventOlds.ForEach(x => - { - x.DataState = (int)SwapFlowDateStateEnum.废弃; - }); + var eodPositions = FindPositions(swapTradeId);//上一日终持仓信息 + FindAndInvalidateFutureEvents(swapTradeId, tradeDate);//废弃当前清算日期及之后的开平仓事件 var mergeUnderlyingGroup = flowquery.GroupBy(g => g.UnderlyingCode); int direction = tradeExtend.ExtendObj.Direction; foreach (var underlyingGroup in mergeUnderlyingGroup) { var mergeList = underlyingGroup.OrderByDescending(o => o.TradingQty).ToList();//先按数量最大的排序 var flowMerge = mergeList.First(); - var underlying = DataCacheProvider.GetUnderlyingDataSource().GetData(flowMerge.UnderlyingCode); + var underlying = GetUnderlying(flowMerge.UnderlyingCode); var matuirityDate = trade.ExerciseDate; - var positionId = GetMaxPositionId(flowMerge, matuirityDate.Value, direction, trade.TradeNumber); + var positionId = ResolvePositionId(flowMerge, matuirityDate.Value, direction, trade.TradeNumber); var payPosition = eodPositions.FirstOrDefault(x => x.PositionId == positionId);//浮动腿 日终持仓信息 bool hasPayPosition = payPosition != null;//是否存在日终持仓 if (mergeList.Count == 1)//只有一条流水 @@ -208,12 +251,12 @@ namespace YLErp.Modules.SwapModule } } - DbContext.SaveChanges(); - trans?.Commit(); + PersistEvents(flowEvents); + if (trans != null) CommitTransaction(trans); } catch (Exception ex) { - trans?.Rollback(); + if (trans != null) RollbackTransaction(trans); throw new Exception(ex.Message, ex); } finally @@ -238,7 +281,7 @@ namespace YLErp.Modules.SwapModule /// 平仓浮动费用 /// 是否完全平仓 /// 0 T+0 1 T+1 - private swap_flow_event InitEvent( + protected virtual swap_flow_event InitEvent( int eventType, swap_flow_merge flow_merge, int direction, @@ -287,7 +330,6 @@ namespace YLErp.Modules.SwapModule flow_Event.MarkClosePnl = PayMarkUnwindPnl; flow_Event.CloseFee = PayFeeUnwindPnl; flow_Event.DataState = (int)SwapFlowDateStateEnum.等待完成; - DbContext.swap_flow_event.Add(flow_Event); return flow_Event; } diff --git a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs index e859fcad..3373a90b 100644 --- a/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapTradeBaseService.cs @@ -388,7 +388,7 @@ namespace YLErp.Modules.SwapModule return false; } - public void UpdateDbOption(DBModelBaseV2 dBModel) + public virtual void UpdateDbOption(DBModelBaseV2 dBModel) { dBModel.OptTime = DateTime.Now; dBModel.OptName = UserName;