Files
zszq-trs/UnitTestProject/Modules/SwapModule/SwapDealSettlementTest.cs
T
hjhan a207927f1b test(swap): SwapDealService抽7个virtual seam + 手动结算内存测试
攻克SwapIncome/SwapUnwind零单元测试覆盖的盲区(此前仅DBRecording,CI不跑)。

SwapDealService补7个protected virtual seam(参考已验证的SwapEodPositionService模式):
- FindTrade/AddClientCash/SaveSwapDeal(原private改protected virtual)
- SaveAllChanges/ExecuteInTransaction/CallSaveSwapTradeClientCash/TriggerRealtimeSwapPosition
生产实现行为不变,SwapIncome/SwapUnwind/ApproveSwapTrade重构为调seam。

新增SwapDealSettlementTest.cs(4个内存测试):
- SD_001 SwapIncome资金流水金额=-SwapRealizedPnL
- SD_002 含预付金返息双流水
- SD_003 SwapUnwind全平仓持仓归零+状态变更
- SD_004 DealFloatPosition含费价重算(后端唯二真做计算处)
红灯验证通过(改错金额符号测试立即失败)。
2026-07-03 08:22:43 +08:00

252 lines
14 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 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;
public List<(double amount, string action, DateTime date)> ClientCashCalls = new();
public List<(UnwindData data, int eventType, int clientCashId)> SaveSwapDealCalls = new();
public int SaveAllChangesCount;
public StubDealService(trade td) : base(new OptUserInfo(0, nameof(SwapDealSettlementTest), OptUserFrom.UnitTest))
{
_trade = td;
}
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
}
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} ✅");
}
}
}