借鉴 origin/glms/feature/refactor-swap-event-testable 的 SwapUnwindScenarioTest 场景4/5, 补全当前分支缺失的平仓审批流程覆盖。 SwapDealService 新增3个 protected virtual seam: - FindSwapEvent(查待审核事件) - FindFlowEventsByEventId(查事件关联流水) - CloseReCheckSetTrade(前置校验包装) ApproveSwapTrade/ApplySwapTrade 重构为调seam,生产行为不变。 SwapDealSettlementTest 新增2个测试(SD_005/006,共6个): - SD_005 ApproveSwapTrade 全平仓审核: 反序列化swap_event.EventData→资金流水→TradeStatus - SD_006 ApplySwapTrade 提交审核: CloseReCheck前置校验+SwapRealizedPnL=SwapCloseAmount 融合策略: 保留当前分支SwapIncome资金流水断言独特价值(SD_001/002), 借鉴testable分支审批流程覆盖(SD_005/006),不二选一。 SwapModule 157测试全绿,无回归。
360 lines
21 KiB
C#
360 lines
21 KiB
C#
using Newtonsoft.Json;
|
||
using YLErp.DBModels;
|
||
using YLErp.DBModels.Enums;
|
||
|
||
namespace YLErp.Modules.SwapModule
|
||
{
|
||
/// <summary>
|
||
/// SwapDealService 手动结算(SwapIncome/SwapUnwind)内存单元测试
|
||
/// ============================================================================
|
||
/// 背景:SwapIncome/SwapUnwind 是写客户资金流水(ClientCashInCashOut)的核心入口,
|
||
/// 此前零单元测试(仅 DBRecording,CI 不跑)。本测试通过 7 个 virtual seam
|
||
/// 把 DB/事务/外部服务打桩,在纯内存下验证控制流、资金流水金额、持仓状态变更。
|
||
///
|
||
/// 命名规范说明(见《互换价格字段命名规范决策文档》):
|
||
/// 本测试引用现状字段(如 PosiGrossPrice/PosiNetPrice)时加对照注释,
|
||
/// 标明其真实含义与规范名,让测试可读、可作规范示范。
|
||
/// - PosiGrossPrice 现状名,实为"期初全价不含费",规范名 EntryDirtyPrice
|
||
/// - PosiNetPrice 现状名,实为"期初全价含费"(非净价!),规范名 EntryDirtyFeePrice
|
||
/// ============================================================================
|
||
[TestClass]
|
||
public class SwapDealSettlementTest
|
||
{
|
||
private const int SwapTradeId = 7700;
|
||
private static readonly DateTime ValueDate = new(2026, 6, 15);
|
||
private static readonly DateTime UnwindDate = new(2026, 6, 16);
|
||
|
||
#region Stub
|
||
|
||
/// <summary>
|
||
/// 继承 SwapDealService,override 7 个 seam,把 DB/事务/外部服务替换为内存收集器。
|
||
/// 生产路径零改动(seam 生产实现 = 原逻辑),测试可纯内存运行。
|
||
/// </summary>
|
||
private sealed class StubDealService : SwapDealService
|
||
{
|
||
private readonly trade _trade;
|
||
private readonly Dictionary<int, swap_event> _swapEvents;
|
||
private readonly Dictionary<long, List<swap_flow_event>> _flowEventsByEventId;
|
||
public List<(double amount, string action, DateTime date)> ClientCashCalls = new();
|
||
public List<(UnwindData data, int eventType, int clientCashId)> SaveSwapDealCalls = new();
|
||
public int SaveAllChangesCount;
|
||
public int CloseReCheckCallCount;
|
||
|
||
public StubDealService(trade td,
|
||
Dictionary<int, swap_event> swapEvents = null,
|
||
Dictionary<long, List<swap_flow_event>> flowEventsByEventId = null)
|
||
: base(new OptUserInfo(0, nameof(SwapDealSettlementTest), OptUserFrom.UnitTest))
|
||
{
|
||
_trade = td;
|
||
_swapEvents = swapEvents ?? new Dictionary<int, swap_event>();
|
||
_flowEventsByEventId = flowEventsByEventId ?? new Dictionary<long, List<swap_flow_event>>();
|
||
}
|
||
|
||
protected override trade FindTrade(int tradeId) => tradeId == _trade.id ? _trade : null;
|
||
|
||
protected override int AddClientCash(trade td, double amount, string action, DateTime valueDate)
|
||
{
|
||
ClientCashCalls.Add((amount, action, valueDate));
|
||
return ClientCashCalls.Count; // 返回自增 id
|
||
}
|
||
|
||
// 整体 override SaveSwapDeal:收集入参,规避内部 new SwapEventService 连库
|
||
protected override long SaveSwapDeal(UnwindData unwindData, int eventType, int clientCashId, string eventResason = "", bool approve = false)
|
||
{
|
||
SaveSwapDealCalls.Add((unwindData, eventType, clientCashId));
|
||
return SaveSwapDealCalls.Count; // 返回自增 eventId
|
||
}
|
||
|
||
// ApproveSwapTrade 查待审核事件:从内存字典取(key=eventType)
|
||
protected override swap_event FindSwapEvent(int tradeId, int eventType)
|
||
{
|
||
return _swapEvents.TryGetValue(eventType, out var evt) ? evt : null;
|
||
}
|
||
|
||
// ApproveSwapTrade 查事件关联流水:从内存字典取
|
||
protected override List<swap_flow_event> FindFlowEventsByEventId(long eventId)
|
||
{
|
||
return _flowEventsByEventId.TryGetValue(eventId, out var list) ? list : new List<swap_flow_event>();
|
||
}
|
||
|
||
// ApplySwapTrade 的前置校验:计数,不实际执行
|
||
protected override void CloseReCheckSetTrade(int swapTradeId, bool isSwap, bool needCheck)
|
||
{
|
||
CloseReCheckCallCount++;
|
||
}
|
||
|
||
protected override void SaveAllChanges() { SaveAllChangesCount++; }
|
||
protected override void ExecuteInTransaction(Action action) => action(); // 不包事务,直接执行
|
||
protected override void CallSaveSwapTradeClientCash(trade td, DateTime valueDate) { } // 空操作
|
||
protected override void TriggerRealtimeSwapPosition() { } // 空操作
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 数据构建
|
||
|
||
private static trade CreateTrade()
|
||
{
|
||
return new trade
|
||
{
|
||
id = SwapTradeId, TradeNumber = "UT-SD-001", ClientId = 888888,
|
||
TradeType = "收益互换", StartDate = new DateTime(2026, 1, 5),
|
||
ExerciseDate = new DateTime(2026, 6, 14), // 已到期边界(SwapIncome 判断用)
|
||
TradeStatus = "确认成交", ValidState = "Valid",
|
||
Notional = 1000000, StockEqvNotional = 1000000, TradeAmount = 10000
|
||
};
|
||
}
|
||
|
||
/// <summary>构造结息/平仓的 UnwindData(金额由前端算好传入,后端直接用)</summary>
|
||
private static UnwindData CreateUnwindData(decimal swapRealizedPnL, decimal swapMarginRebatePnl = 0m,
|
||
decimal swapMarginAmount = 0m, int closeMethod = 0, decimal closePercent = 0m,
|
||
decimal closeQty = 0m, decimal closeNotionalValue = 0m, decimal positionQty = 0m)
|
||
{
|
||
return new UnwindData
|
||
{
|
||
SwapTradeId = SwapTradeId,
|
||
SwapRealizedPnL = swapRealizedPnL,
|
||
SwapMarginRebatePnl = swapMarginRebatePnl,
|
||
SwapMarginAmount = swapMarginAmount,
|
||
SwapCloseAmount = swapRealizedPnL,
|
||
CloseMethod = closeMethod,
|
||
ClosePercent = closePercent,
|
||
CloseQty = closeQty,
|
||
CloseNotionalValue = closeNotionalValue,
|
||
PositionQty = positionQty,
|
||
ValueDate = ValueDate,
|
||
UnwindDate = UnwindDate,
|
||
StartDate = new DateTime(2026, 1, 5)
|
||
};
|
||
}
|
||
|
||
#endregion
|
||
|
||
// ================================================================
|
||
// SD_001:SwapIncome 正常结息 —— 验证资金流水金额正确
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// [SD_001] SwapIncome 正常结息:SwapRealizedPnL=1000 → 客户资金流水金额=-1000
|
||
/// ------------------------------------------------------------
|
||
/// 后端 SwapDealService.cs:1553 直接用前端传入的 SwapRealizedPnL 记账:
|
||
/// AddClientCash(td, -SwapRealizedPnL, 系统操作_互换, ValueDate)
|
||
/// 本测试锁定:资金流水金额 = -SwapRealizedPnL,事件类型 = 互换(3)。
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void SD_001_SwapIncome_正常结息_资金流水金额正确()
|
||
{
|
||
var td = CreateTrade();
|
||
td.ExerciseDate = new DateTime(2026, 12, 31); // 未到期,不走"已到期"分支
|
||
var service = new StubDealService(td);
|
||
var unwindData = CreateUnwindData(swapRealizedPnL: 1000m);
|
||
|
||
service.SwapIncome(unwindData);
|
||
|
||
Assert.AreEqual(1, service.ClientCashCalls.Count, "应生成1条资金流水(互换)");
|
||
Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水金额 = -SwapRealizedPnL");
|
||
Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action, "操作类型=系统操作_互换");
|
||
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次");
|
||
Assert.AreEqual((int)SwapEventTypeEnum.互换, service.SaveSwapDealCalls[0].eventType, "事件类型=互换(3)");
|
||
Console.WriteLine($"SD_001 通过:资金流水金额={service.ClientCashCalls[0].amount},事件类型=互换 ✅");
|
||
}
|
||
|
||
// ================================================================
|
||
// SD_002:SwapIncome 含预付金返息 —— 两条资金流水
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// [SD_002] SwapIncome 含预付金返息:SwapRealizedPnL=1000, SwapMarginRebatePnl=200
|
||
/// → 生成2条资金流水(互换 + 预付金返息),金额分别为 -1000、-200
|
||
/// 后端 SwapDealService.cs:1556 条件:SwapMarginRebatePnl != 0 时追加预付金返息流水。
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void SD_002_SwapIncome_含预付金返息_两条资金流水()
|
||
{
|
||
var td = CreateTrade();
|
||
td.ExerciseDate = new DateTime(2026, 12, 31);
|
||
var service = new StubDealService(td);
|
||
var unwindData = CreateUnwindData(swapRealizedPnL: 1000m, swapMarginRebatePnl: 200m);
|
||
|
||
service.SwapIncome(unwindData);
|
||
|
||
Assert.AreEqual(2, service.ClientCashCalls.Count, "应生成2条资金流水(互换+预付金返息)");
|
||
Assert.AreEqual(-1000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=互换金额 -SwapRealizedPnL");
|
||
Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action);
|
||
Assert.AreEqual(-200.0, service.ClientCashCalls[1].amount, 0.001, "第2条=预付金返息 -SwapMarginRebatePnl");
|
||
Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[1].action);
|
||
Console.WriteLine($"SD_002 通过:2条资金流水,互换={service.ClientCashCalls[0].amount},预付金返息={service.ClientCashCalls[1].amount} ✅");
|
||
}
|
||
|
||
// ================================================================
|
||
// SD_003:SwapUnwind 全平仓 —— 持仓归零、资金流水、状态变更
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// [SD_003] SwapUnwind 全平仓:ClosePercent=1 → TradeStatus=已平仓、持仓扣减、资金流水正确
|
||
/// 后端 SwapDealService.cs SwapUnwind:全平时 TradeStatus=已平仓,StockEqvNotional/TradeAmount 扣减。
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void SD_003_SwapUnwind_正常平仓_资金流水与持仓状态正确()
|
||
{
|
||
var td = CreateTrade();
|
||
var service = new StubDealService(td);
|
||
// 全平:ClosePercent=1, CloseQty=10000, CloseNotionalValue=1000000
|
||
var unwindData = CreateUnwindData(
|
||
swapRealizedPnL: 5000m, swapMarginAmount: 0m,
|
||
closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m,
|
||
closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m);
|
||
|
||
service.SwapUnwind(unwindData);
|
||
|
||
// 资金流水:平仓费 = -SwapRealizedPnL
|
||
Assert.AreEqual(1, service.ClientCashCalls.Count, "全平无预付金时应1条资金流水");
|
||
Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL");
|
||
Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action);
|
||
// 持仓状态
|
||
Assert.AreEqual("已平仓", td.TradeStatus, "全平仓 TradeStatus=已平仓");
|
||
// 全平仓走"已平仓"分支,不设 HasPartialUnWind(仅部分平仓才设=1)
|
||
Assert.AreNotEqual(1, td.HasPartialUnWind, "全平仓不应设 HasPartialUnWind(仅部分平仓设=1)");
|
||
// 持仓扣减:原 StockEqvNotional=1000000 - CloseNotionalValue=1000000 = 0
|
||
Assert.AreEqual(0.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=0");
|
||
Assert.AreEqual(0.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=0");
|
||
// 事件类型
|
||
Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓(2)");
|
||
Console.WriteLine($"SD_003 通过:TradeStatus={td.TradeStatus},StockEqvNotional={td.StockEqvNotional} ✅");
|
||
}
|
||
|
||
// ================================================================
|
||
// SD_004:DealFloatPosition 含费价重算正确(后端唯二真做计算的地方)
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// [SD_004] DealFloatPosition 含费价重算(SwapDealService.cs:1713-1725)
|
||
/// ------------------------------------------------------------
|
||
/// 平仓事件重算三个字段(规范语义,见命名文档):
|
||
/// TradingAmountFeeAvg(ExitDirtyFeePrice)= TradingAmountAvg(ExitDirtyPrice) + TradingFeePending/CloseQty × shortRatio
|
||
/// TradingAmountNetFeeAvg(ExitCleanFeePrice)= TradingAmountNetAvg(ExitCleanPrice) + TradingFeePending/CloseQty × shortRatio
|
||
/// TradingAmount = TradingAmountAvg × CloseQty
|
||
/// 这是后端少数真正做计算(而非透传前端值)的地方,需锁住。
|
||
///
|
||
/// 手算:ExitDirtyPrice=1.02, TradingFeePending=50, CloseQty=1000, Long(多头,shortRatio=-1)
|
||
/// ExitDirtyFeePrice = 1.02 + 50/1000 × (-1) = 1.02 - 0.05 = 0.97
|
||
/// ExitCleanFeePrice = 1.00 + 50/1000 × (-1) = 1.00 - 0.05 = 0.95
|
||
/// TradingAmount = 1.02 × 1000 = 1020
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void SD_004_DealFloatPosition_含费价重算正确()
|
||
{
|
||
var td = CreateTrade();
|
||
var service = new StubDealService(td);
|
||
|
||
// 构造平仓事件(PositionType>0 触发重算)
|
||
var closeEvent = new swap_flow_event
|
||
{
|
||
EventType = (int)SwapEventTypeEnum.平仓,
|
||
PositionType = (int)PositionTypeFlag.Long, // 多头,shortRatio=-1
|
||
// TradingAmountAvg 现状名,实为"期末全价不含费",规范名 ExitDirtyPrice
|
||
TradingAmountAvg = 1.02m,
|
||
// TradingAmountNetAvg 现状名,实为"期末净价不含费",规范名 ExitCleanPrice
|
||
TradingAmountNetAvg = 1.00m,
|
||
TradingFeePending = 50m,
|
||
};
|
||
var unwindData = CreateUnwindData(swapRealizedPnL: 0m, closeQty: 1000m);
|
||
unwindData.FlowEvents.Add(closeEvent);
|
||
|
||
service.SwapUnwind(unwindData);
|
||
|
||
// ExitDirtyFeePrice(TradingAmountFeeAvg)= 1.02 + 50/1000×(-1) = 0.97
|
||
Assert.AreEqual(0.97m, closeEvent.TradingAmountFeeAvg, 0.0001m,
|
||
$"TradingAmountFeeAvg(ExitDirtyFeePrice) 应=ExitDirtyPrice(1.02)+Fee/CloseQty×(-1)=0.97,实际={closeEvent.TradingAmountFeeAvg}");
|
||
// ExitCleanFeePrice(TradingAmountNetFeeAvg)= 1.00 + 50/1000×(-1) = 0.95
|
||
Assert.AreEqual(0.95m, closeEvent.TradingAmountNetFeeAvg ?? 0m, 0.0001m,
|
||
$"TradingAmountNetFeeAvg(ExitCleanFeePrice) 应=ExitCleanPrice(1.00)+Fee/CloseQty×(-1)=0.95,实际={closeEvent.TradingAmountNetFeeAvg}");
|
||
// TradingAmount = ExitDirtyPrice × CloseQty = 1.02 × 1000 = 1020
|
||
Assert.AreEqual(1020m, closeEvent.TradingAmount, 0.0001m,
|
||
$"TradingAmount 应=ExitDirtyPrice(1.02)×CloseQty(1000)=1020,实际={closeEvent.TradingAmount}");
|
||
Console.WriteLine($"SD_004 通过:ExitDirtyFeePrice={closeEvent.TradingAmountFeeAvg},ExitCleanFeePrice={closeEvent.TradingAmountNetFeeAvg},TradingAmount={closeEvent.TradingAmount} ✅");
|
||
}
|
||
|
||
// ================================================================
|
||
// SD_005:ApproveSwapTrade 审核通过 —— 反序列化事件、资金流水、持仓状态
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// [SD_005] ApproveSwapTrade 审核通过全部平仓
|
||
/// ------------------------------------------------------------
|
||
/// 后端 SwapDealService.ApproveSwapTrade:从 swap_event.EventData 反序列化 UnwindData,
|
||
/// 据此生成资金流水 + 更新持仓状态。
|
||
/// 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario4,验证:
|
||
/// - SwapRealizedPnL 从事件反序列化正确(EventData JSON)
|
||
/// - 资金流水金额 = -SwapRealizedPnL
|
||
/// - 全平仓 → TradeStatus=已平仓
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void SD_005_ApproveSwapTrade_全平仓审核_反序列化事件并记账()
|
||
{
|
||
var td = CreateTrade();
|
||
// 构造待审核事件:EventData 里序列化了 UnwindData(含 SwapRealizedPnL=8000)
|
||
var unwindData = CreateUnwindData(swapRealizedPnL: 8000m,
|
||
closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m,
|
||
closeQty: 10000m, closeNotionalValue: 1000000m);
|
||
var swapEvent = new swap_event
|
||
{
|
||
id = 1, SwapTradeId = SwapTradeId,
|
||
EventType = (int)SwapEventTypeEnum.平仓, Invalid = false,
|
||
EventData = JsonConvert.SerializeObject(unwindData)
|
||
};
|
||
var flowEvents = new Dictionary<long, List<swap_flow_event>>
|
||
{
|
||
[1] = new List<swap_flow_event> { new swap_flow_event { id = 1, EventId = 1, PositionId = 1 } }
|
||
};
|
||
var service = new StubDealService(td,
|
||
swapEvents: new Dictionary<int, swap_event> { [(int)SwapEventTypeEnum.平仓] = swapEvent },
|
||
flowEventsByEventId: flowEvents);
|
||
|
||
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓);
|
||
|
||
// 资金流水:从反序列化的 SwapRealizedPnL(8000) 记账 → -8000
|
||
Assert.AreEqual(1, service.ClientCashCalls.Count, "全平仓无预付金时应1条资金流水");
|
||
Assert.AreEqual(-8000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-反序列化的SwapRealizedPnL");
|
||
// 持仓状态
|
||
Assert.AreEqual("已平仓", td.TradeStatus, "审核全平仓 TradeStatus=已平仓");
|
||
Console.WriteLine($"SD_005 通过:审核反序列化 SwapRealizedPnL=8000,资金流水={service.ClientCashCalls[0].amount},TradeStatus={td.TradeStatus} ✅");
|
||
}
|
||
|
||
// ================================================================
|
||
// SD_006:ApplySwapTrade 提交审核 —— 前置校验 + 保存事件
|
||
// ================================================================
|
||
|
||
/// <summary>
|
||
/// [SD_006] ApplySwapTrade 提交审核
|
||
/// ------------------------------------------------------------
|
||
/// 后端 SwapDealService.ApplySwapTrade:调 CloseReCheckSetTrade 前置校验 + SaveSwapDeal(approve=true)。
|
||
/// 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario5,验证:
|
||
/// - CloseReCheckSetTrade 被调用1次
|
||
/// - SaveSwapDeal 以 approve=true 调用(事件类型正确)
|
||
/// - SwapRealizedPnL = SwapCloseAmount(ApplySwapTrade 内部赋值)
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void SD_006_ApplySwapTrade_提交审核_前置校验与保存事件()
|
||
{
|
||
var td = CreateTrade();
|
||
var service = new StubDealService(td);
|
||
// 前端提交时 SwapCloseAmount=6000(前端算好的总额),SwapRealizedPnL 初始可能为0
|
||
var unwindData = CreateUnwindData(swapRealizedPnL: 0m);
|
||
unwindData.SwapCloseAmount = 6000m; // 模拟前端传入的平仓总额
|
||
|
||
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓);
|
||
|
||
// 前置校验被调用
|
||
Assert.AreEqual(1, service.CloseReCheckCallCount, "应调用 CloseReCheckSetTrade 1次");
|
||
// SaveSwapDeal 以 approve=true 调用
|
||
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次");
|
||
Assert.AreEqual((int)SwapEventTypeEnum.平仓, service.SaveSwapDealCalls[0].eventType, "事件类型=平仓");
|
||
// SwapRealizedPnL 应被赋值为 SwapCloseAmount(ApplySwapTrade 内部 cs:1631)
|
||
Assert.AreEqual(6000m, service.SaveSwapDealCalls[0].data.SwapRealizedPnL, 0.001m,
|
||
"SwapRealizedPnL 应=SwapCloseAmount(6000)");
|
||
Console.WriteLine($"SD_006 通过:CloseReCheck 调用{service.CloseReCheckCallCount}次,SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅");
|
||
}
|
||
}
|
||
}
|