Files
zszq-trs/UnitTestProject/Modules/SwapModule/MergePageEventScenarioTest.cs
T
hjhan f0ef37b599 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),无回归。
2026-07-03 09:26:13 +08:00

289 lines
11 KiB
C#

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
}
}