Files
zszq-trs/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs
T
hjhan 129e4bed0d test(swap): 补ApproveSwapTrade/ApplySwapTrade测试+3个seam(借鉴testable分支)
借鉴 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测试全绿,无回归。
2026-07-03 08:30:36 +08:00

360 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
/// 继承 SwapDealServiceoverride 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_001SwapIncome 正常结息 —— 验证资金流水金额正确
// ================================================================
/// <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_002SwapIncome 含预付金返息 —— 两条资金流水
// ================================================================
/// <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_003SwapUnwind 全平仓 —— 持仓归零、资金流水、状态变更
// ================================================================
/// <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_004DealFloatPosition 含费价重算正确(后端唯二真做计算的地方)
// ================================================================
/// <summary>
/// [SD_004] DealFloatPosition 含费价重算(SwapDealService.cs:1713-1725
/// ------------------------------------------------------------
/// 平仓事件重算三个字段(规范语义,见命名文档):
/// TradingAmountFeeAvgExitDirtyFeePrice= TradingAmountAvg(ExitDirtyPrice) + TradingFeePending/CloseQty × shortRatio
/// TradingAmountNetFeeAvgExitCleanFeePrice= 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);
// ExitDirtyFeePriceTradingAmountFeeAvg= 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}");
// ExitCleanFeePriceTradingAmountNetFeeAvg= 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_005ApproveSwapTrade 审核通过 —— 反序列化事件、资金流水、持仓状态
// ================================================================
/// <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_006ApplySwapTrade 提交审核 —— 前置校验 + 保存事件
// ================================================================
/// <summary>
/// [SD_006] ApplySwapTrade 提交审核
/// ------------------------------------------------------------
/// 后端 SwapDealService.ApplySwapTrade:调 CloseReCheckSetTrade 前置校验 + SaveSwapDeal(approve=true)。
/// 借鉴 testable 分支 SwapUnwindScenarioTest.Scenario5,验证:
/// - CloseReCheckSetTrade 被调用1次
/// - SaveSwapDeal 以 approve=true 调用(事件类型正确)
/// - SwapRealizedPnL = SwapCloseAmountApplySwapTrade 内部赋值)
/// </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 应被赋值为 SwapCloseAmountApplySwapTrade 内部 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} ✅");
}
}
}