借鉴 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),无回归。
289 lines
11 KiB
C#
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
|
|
}
|
|
}
|