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;