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:
@@ -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() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user