test(swap): SwapFlowEventService seam改造+MergePageEventScenarioTest(6场景全绿)

借鉴 testable 分支,只取 seam(排除 PosiPnl/NetSettmentAmount 公式改动和 MergePageEvent 拆分),
填补当前分支对开平仓合成(MergePageEvent)零覆盖的空白。

SwapFlowEventService 新增11个 protected virtual seam:
- FindTrade/FindTradeExtend/FindPositions/FindAndInvalidateFutureEvents
- GetUnderlying/GetNextBusinessDay/ResolvePositionId
- PersistEvents(替代InitEvent内联Add+SaveChanges)/BeginTransaction/CommitTransaction/RollbackTransaction
MergePageEvent/InitEvent: private→protected virtual,内联DB调用替换为seam。
InitEvent 移除 DbContext.swap_flow_event.Add(转入PersistEvents,纯函数化)。
SwapTradeBaseService.UpdateDbOption: public void→public virtual(测试override用)。

新增测试(借鉴testable分支):
- TestableSwapFlowEventService.cs(集中式共享包装类)
- MergePageEventScenarioTest.cs(6场景全绿):
  Scenario1 单条无持仓开仓 / Scenario2 同向追加 / Scenario3 反向全平
  Scenario4 反向部分平+开 / Scenario5 一开一平 / Scenario6 复杂组合

**未借鉴**:SearchPositionFlowEvent的PosiPnl/NetSettmentAmount公式改动(行为变更)、
MergePageEvent拆分ProcessSingleFlow等(非必需重构)。
SwapModule 169测试全绿(+6),无回归。
This commit is contained in:
hjhan
2026-07-03 09:26:13 +08:00
parent 6e9bae94c5
commit f0ef37b599
4 changed files with 441 additions and 18 deletions
@@ -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
{
/// <summary>
/// 用构造数据覆盖 MergePageEvent 全部 6 种场景
/// 不连数据库,纯内存,秒级运行
/// </summary>
[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<swap_position>());
var merges = new List<swap_flow_merge>
{
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<swap_position>
{
CreatePosition(PositionType: 1, Qty: 50000, GrossPrice: 0.99m)
};
var service = CreateService(positions);
var merges = new List<swap_flow_merge>
{
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<swap_position>
{
CreatePosition(PositionType: 2, Qty: 100000, GrossPrice: 0.98m)
};
var service = CreateService(positions);
// 买入100000,但持仓是空头100000 → 全部平仓
var merges = new List<swap_flow_merge>
{
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<swap_position>
{
CreatePosition(PositionType: 2, Qty: 30000, GrossPrice: 0.98m)
};
var service = CreateService(positions);
// 买入100000,持仓空头30000 → 先平30000,再开70000
var merges = new List<swap_flow_merge>
{
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<swap_position>());
var merges = new List<swap_flow_merge>
{
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<swap_position>
{
CreatePosition(PositionType: 2, Qty: 30000, GrossPrice: 0.98m) // 空头持仓
};
var service = CreateService(positions);
// 买10万 + 卖5万,持仓空头3万
var merges = new List<swap_flow_merge>
{
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<swap_position> 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<string, underlying_manager>
{
[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
}
}
@@ -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
{
/// <summary>
/// 可测试的 SwapFlowEventService 子类
/// override 所有外部依赖方法,用内存数据替代数据库和静态调用
/// </summary>
public class TestableSwapFlowEventService : SwapFlowEventService
{
private readonly trade _trade;
private readonly trade_extend _tradeExtend;
private readonly List<swap_position> _positions;
private readonly Dictionary<string, underlying_manager> _underlyings;
private readonly Func<DateTime, DateTime> _nextBusinessDay;
private readonly long _positionId;
public List<swap_flow_event> PersistedEvents { get; } = new List<swap_flow_event>();
public TestableSwapFlowEventService(
OptUserInfo optUser,
trade trade,
trade_extend tradeExtend,
List<swap_position> positions,
Dictionary<string, underlying_manager> underlyings,
Func<DateTime, DateTime> nextBusinessDay,
long positionId = 999999
) : base(optUser)
{
_trade = trade;
_tradeExtend = tradeExtend;
_positions = positions ?? new List<swap_position>();
_underlyings = underlyings ?? new Dictionary<string, underlying_manager>();
_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<swap_position> FindPositions(int swapTradeId) => _positions;
protected override List<swap_flow_event> FindAndInvalidateFutureEvents(int swapTradeId, DateTime tradeDate)
{
// 测试环境:不需要废弃历史事件
return new List<swap_flow_event>();
}
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<swap_flow_event> events)
{
// 不写 DB,收集到列表供验证
PersistedEvents.AddRange(events);
}
protected override IDisposable BeginTransaction() => new NoopDisposable();
protected override void CommitTransaction(IDisposable transaction) { }
protected override void RollbackTransaction(IDisposable transaction) { }
/// <summary>公开调用 protected 的 MergePageEvent,供测试使用</summary>
public List<swap_flow_event> ExecuteMergePageEvent(int swapTradeId, List<swap_flow_merge> 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() { }
}
}
}
@@ -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<swap_position> FindPositions(int swapTradeId)
=> DbContext.swap_position.Where(x => x.SwapTradeId == swapTradeId && !x.IsInitial && !x.Invalid).AsNoTracking().ToList();
protected virtual List<swap_flow_event> 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<swap_flow_event> 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
/// <summary>
/// 互换流水开平仓事件
/// </summary>
@@ -70,7 +117,7 @@ namespace YLErp.Modules.SwapModule
/// 分页处理互换流水开平仓事件,暂时只按加权平均处理
/// </summary>
/// <param name="pageSize"></param>
private List<swap_flow_event> MergePageEvent(int swapTradeId, List<swap_flow_merge> flowMergeList, DateTime tradeDate, bool needTrans = true)
protected virtual List<swap_flow_event> MergePageEvent(int swapTradeId, List<swap_flow_merge> flowMergeList, DateTime tradeDate, bool needTrans = true)
{
List<swap_flow_event> flowEvents = new List<swap_flow_event>();
//按照同一互换编码、标的、买卖方向排序,一条买,一条卖//会存在买卖不在同一页
@@ -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
/// <param name="PayFeeUnwindPnl">平仓浮动费用</param>
/// <param name="allUnwind">是否完全平仓</param>
/// <param name="settleRules">0 T+0 1 T+1</param>
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;
}
@@ -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;