refactor(test): SwapDealSettlementTest拆分为SwapUnwind/SwapIncome+提取共享stub

按业务拆分原 SwapDealSettlementTest(8场景)为3个文件,提升可读性:
- TestableSwapDealService.cs: 共享stub(7个seam override)+工厂方法(CreateTrade/CreateUnwindData)
- SwapUnwindScenarioTest.cs: 平仓全流程6场景(UW_001~006: 全平/部分平/含预付金/含费价重算/审核/提交)
- SwapIncomeScenarioTest.cs: 结息2场景(SI_001~002: 正常结息/含预付金返息)

命名对齐 testable 分支风格(SwapUnwindScenarioTest),场景前缀改 UW_/SI_ 更直观。
删除 SwapDealSettlementTest.cs(内容已拆分迁移)。
SwapModule 173测试全绿,无回归。
This commit is contained in:
hjhan
2026-07-03 18:12:52 +08:00
parent 99958f9b43
commit 4fede8339a
4 changed files with 385 additions and 426 deletions
@@ -1,426 +0,0 @@
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_007SwapUnwind 部分平仓 —— HasPartialUnWind=1TradeStatus 不变
// ================================================================
/// <summary>
/// [SD_007] SwapUnwind 部分平仓(借鉴 testable SwapUnwindScenarioTest.Scenario2
/// ------------------------------------------------------------
/// 部分平仓(CloseMethod≠全部平仓 且 ClosePercent≠1)走 else 分支:
/// td.HasPartialUnWind = 1TradeStatus 保持"确认成交"不变
/// StockEqvNotional/TradeAmount 仍按 CloseNotionalValue/CloseQty 扣减
/// </summary>
[TestMethod]
public void SD_007_SwapUnwind_部分平仓_设HasPartialUnWind且TradeStatus不变()
{
var td = CreateTrade();
var service = new StubDealService(td);
// 部分平仓:ClosePercent=0.5, CloseQty=5000, CloseNotionalValue=500000
var unwindData = CreateUnwindData(
swapRealizedPnL: 3000m, swapMarginAmount: 0m,
closeMethod: (int)CloseMethodEnum., closePercent: 0.5m,
closeQty: 5000m, closeNotionalValue: 500000m, positionQty: 10000m);
service.SwapUnwind(unwindData);
// 部分平仓:设 HasPartialUnWind=1TradeStatus 不变
Assert.AreEqual(1, td.HasPartialUnWind, "部分平仓应设 HasPartialUnWind=1");
Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变");
// 持仓扣减:原 StockEqvNotional=1000000 - 500000 = 500000
Assert.AreEqual(500000.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=500000");
Assert.AreEqual(5000.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=5000");
// 资金流水仍有(部分平仓也产生平仓费流水)
Assert.AreEqual(1, service.ClientCashCalls.Count, "部分平仓应1条资金流水");
Assert.AreEqual(-3000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL");
Console.WriteLine($"SD_007 通过:HasPartialUnWind={td.HasPartialUnWind}TradeStatus={td.TradeStatus}StockEqvNotional={td.StockEqvNotional} ✅");
}
// ================================================================
// SD_008SwapUnwind 含预付金 —— 两条资金流水(平仓费+应付预付金)
// ================================================================
/// <summary>
/// [SD_008] SwapUnwind 含预付金返还(SwapMarginAmount≠0
/// ------------------------------------------------------------
/// SwapUnwind 内部:SwapMarginAmount≠0 时追加一条"应付预付金"资金流水。
/// 验证:2条流水(平仓费 + 应付预付金),金额和操作类型正确。
/// </summary>
[TestMethod]
public void SD_008_SwapUnwind_含预付金_两条资金流水()
{
var td = CreateTrade();
var service = new StubDealService(td);
var unwindData = CreateUnwindData(
swapRealizedPnL: 5000m, swapMarginAmount: 2000m,
closeMethod: (int)CloseMethodEnum., closePercent: 1m,
closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m);
service.SwapUnwind(unwindData);
// 2条资金流水:平仓费 + 应付预付金
Assert.AreEqual(2, service.ClientCashCalls.Count, "含预付金时应2条资金流水");
Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=平仓费 -SwapRealizedPnL");
Assert.AreEqual(ClientCashInCashOut._平仓费, service.ClientCashCalls[0].action);
Assert.AreEqual(2000.0, service.ClientCashCalls[1].amount, 0.001, "第2条=应付预付金 SwapMarginAmount");
Assert.AreEqual(ClientCashInCashOut._应付预付金, service.ClientCashCalls[1].action);
Console.WriteLine($"SD_008 通过:2条流水,平仓费={service.ClientCashCalls[0].amount},应付预付金={service.ClientCashCalls[1].amount} ✅");
}
// ================================================================
// 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} ✅");
}
}
}
@@ -0,0 +1,67 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 互换结息(SwapIncome)测试
/// ============================================================================
/// 借鉴 testable 分支命名,基于当前分支 TestableSwapDealService 共享 stub。
/// SwapIncome 是写客户资金流水(ClientCashInCashOut)的核心入口之一。
/// ============================================================================
[TestClass]
public class SwapIncomeScenarioTest
{
// ================================================================
// 场景1SwapIncome 正常结息 —— 资金流水金额正确
// ================================================================
/// <summary>
/// SwapIncome 正常结息:SwapRealizedPnL=1000 → 客户资金流水金额=-1000。
/// 后端 SwapDealService SwapIncome 直接用前端传入的 SwapRealizedPnL 记账。
/// </summary>
[TestMethod]
public void SI_001_SwapIncome_正常结息_资金流水金额正确()
{
var td = SwapDealTestFactory.CreateTrade();
td.ExerciseDate = new DateTime(2026, 12, 31); // 未到期,不走"已到期"分支
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.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($"SI_001: 资金流水={service.ClientCashCalls[0].amount}, 事件类型=互换 ✅");
}
// ================================================================
// 场景2SwapIncome 含预付金返息 —— 两条资金流水
// ================================================================
/// <summary>
/// SwapIncome 含预付金返息:SwapRealizedPnL=1000, SwapMarginRebatePnl=200
/// → 生成2条资金流水(互换 + 预付金返息),金额分别为 -1000、-200。
/// </summary>
[TestMethod]
public void SI_002_SwapIncome_含预付金返息_两条资金流水()
{
var td = SwapDealTestFactory.CreateTrade();
td.ExerciseDate = new DateTime(2026, 12, 31);
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.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条=互换金额");
Assert.AreEqual(ClientCashInCashOut._互换, service.ClientCashCalls[0].action);
Assert.AreEqual(-200.0, service.ClientCashCalls[1].amount, 0.001, "第2条=预付金返息");
Assert.AreEqual(ClientCashInCashOut._预付金返息, service.ClientCashCalls[1].action);
Console.WriteLine($"SI_002: 互换={service.ClientCashCalls[0].amount}, 预付金返息={service.ClientCashCalls[1].amount} ✅");
}
}
}
@@ -0,0 +1,191 @@
using Newtonsoft.Json;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 互换平仓全流程测试(SwapUnwind/ApproveSwapTrade/ApplySwapTrade/DealFloatPosition
/// ============================================================================
/// 借鉴 testable 分支 SwapUnwindScenarioTest,基于当前分支 TestableSwapDealService 共享 stub。
/// 命名规范说明(见《互换价格字段命名规范决策文档》):
/// PosiGrossPrice 现状名,实为"期初全价不含费",规范名 EntryDirtyPrice
/// TradingAmountAvg 现状名,实为"期末全价不含费",规范名 ExitDirtyPrice
/// ============================================================================
[TestClass]
public class SwapUnwindScenarioTest
{
// ================================================================
// 场景1SwapUnwind 全平仓 —— 持仓归零、TradeStatus=已平仓
// ================================================================
[TestMethod]
public void UW_001_SwapUnwind_全平仓_持仓归零且资金流水正确()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 5000m, swapMarginAmount: 0m,
closeMethod: (int)CloseMethodEnum., closePercent: 1m,
closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m);
service.SwapUnwind(unwindData);
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=已平仓");
Assert.AreNotEqual(1, td.HasPartialUnWind, "全平仓不应设 HasPartialUnWind");
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($"UW_001: TradeStatus={td.TradeStatus}, StockEqvNotional={td.StockEqvNotional} ✅");
}
// ================================================================
// 场景2SwapUnwind 部分平仓 —— HasPartialUnWind=1TradeStatus 不变
// ================================================================
[TestMethod]
public void UW_002_SwapUnwind_部分平仓_设HasPartialUnWind且TradeStatus不变()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 3000m, swapMarginAmount: 0m,
closeMethod: (int)CloseMethodEnum., closePercent: 0.5m,
closeQty: 5000m, closeNotionalValue: 500000m, positionQty: 10000m);
service.SwapUnwind(unwindData);
Assert.AreEqual(1, td.HasPartialUnWind, "部分平仓应设 HasPartialUnWind=1");
Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变");
Assert.AreEqual(500000.0, td.StockEqvNotional, 0.001, "StockEqvNotional 扣减后=500000");
Assert.AreEqual(5000.0, td.TradeAmount, 0.001, "TradeAmount 扣减后=5000");
Assert.AreEqual(1, service.ClientCashCalls.Count, "部分平仓应1条资金流水");
Assert.AreEqual(-3000.0, service.ClientCashCalls[0].amount, 0.001, "资金流水=-SwapRealizedPnL");
Console.WriteLine($"UW_002: HasPartialUnWind={td.HasPartialUnWind}, TradeStatus={td.TradeStatus} ✅");
}
// ================================================================
// 场景3SwapUnwind 含预付金 —— 两条资金流水
// ================================================================
[TestMethod]
public void UW_003_SwapUnwind_含预付金_两条资金流水()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 5000m, swapMarginAmount: 2000m,
closeMethod: (int)CloseMethodEnum., closePercent: 1m,
closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m);
service.SwapUnwind(unwindData);
Assert.AreEqual(2, service.ClientCashCalls.Count, "含预付金时应2条资金流水");
Assert.AreEqual(-5000.0, service.ClientCashCalls[0].amount, 0.001, "第1条=平仓费");
Assert.AreEqual(ClientCashInCashOut._平仓费, service.ClientCashCalls[0].action);
Assert.AreEqual(2000.0, service.ClientCashCalls[1].amount, 0.001, "第2条=应付预付金");
Assert.AreEqual(ClientCashInCashOut._应付预付金, service.ClientCashCalls[1].action);
Console.WriteLine($"UW_003: 平仓费={service.ClientCashCalls[0].amount}, 应付预付金={service.ClientCashCalls[1].amount} ✅");
}
// ================================================================
// 场景4DealFloatPosition 含费价重算(后端唯二真做计算的地方)
// ================================================================
/// <summary>
/// 平仓事件重算三字段(SwapDealService DealFloatPosition):
/// TradingAmountFeeAvg(ExitDirtyFeePrice) = TradingAmountAvg(ExitDirtyPrice) + Fee/CloseQty × shortRatio
/// TradingAmountNetFeeAvg(ExitCleanFeePrice) = TradingAmountNetAvg(ExitCleanPrice) + Fee/CloseQty × shortRatio
/// TradingAmount = TradingAmountAvg × CloseQty
/// 手算:ExitDirtyPrice=1.02, Fee=50, CloseQty=1000, Long(shortRatio=-1)
/// ExitDirtyFeePrice = 1.02 + 50/1000×(-1) = 0.97
/// ExitCleanFeePrice = 1.00 + 50/1000×(-1) = 0.95
/// TradingAmount = 1.02 × 1000 = 1020
/// </summary>
[TestMethod]
public void UW_004_DealFloatPosition_含费价重算正确()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var closeEvent = new swap_flow_event
{
EventType = (int)SwapEventTypeEnum.,
PositionType = (int)PositionTypeFlag.Long,
TradingAmountAvg = 1.02m, // ExitDirtyPrice
TradingAmountNetAvg = 1.00m, // ExitCleanPrice
TradingFeePending = 50m,
};
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m, closeQty: 1000m);
unwindData.FlowEvents.Add(closeEvent);
service.SwapUnwind(unwindData);
Assert.AreEqual(0.97m, closeEvent.TradingAmountFeeAvg, 0.0001m,
$"TradingAmountFeeAvg(ExitDirtyFeePrice)=ExitDirtyPrice+Fee/Qty×(-1)=0.97");
Assert.AreEqual(0.95m, closeEvent.TradingAmountNetFeeAvg ?? 0m, 0.0001m,
$"TradingAmountNetFeeAvg(ExitCleanFeePrice)=ExitCleanPrice+Fee/Qty×(-1)=0.95");
Assert.AreEqual(1020m, closeEvent.TradingAmount, 0.0001m,
$"TradingAmount=ExitDirtyPrice×CloseQty=1020");
Console.WriteLine($"UW_004: ExitDirtyFeePrice={closeEvent.TradingAmountFeeAvg}, TradingAmount={closeEvent.TradingAmount} ✅");
}
// ================================================================
// 场景5ApproveSwapTrade 审核通过全平仓 —— 反序列化事件并记账
// ================================================================
[TestMethod]
public void UW_005_ApproveSwapTrade_全平仓审核_反序列化事件并记账()
{
var td = SwapDealTestFactory.CreateTrade();
td.ExerciseDate = new DateTime(2026, 12, 31);
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 8000m,
closeMethod: (int)CloseMethodEnum., closePercent: 1m,
closeQty: 10000m, closeNotionalValue: 1000000m);
var swapEvent = new swap_event
{
id = 1, SwapTradeId = SwapDealTestFactory.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 TestableSwapDealService(td,
swapEvents: new Dictionary<int, swap_event> { [(int)SwapEventTypeEnum.] = swapEvent },
flowEventsByEventId: flowEvents);
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.);
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($"UW_005: 反序列化SwapRealizedPnL=8000, 资金流水={service.ClientCashCalls[0].amount}, TradeStatus={td.TradeStatus} ✅");
}
// ================================================================
// 场景6ApplySwapTrade 提交审核 —— 前置校验与保存事件
// ================================================================
[TestMethod]
public void UW_006_ApplySwapTrade_提交审核_前置校验与保存事件()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m);
unwindData.SwapCloseAmount = 6000m;
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.);
Assert.AreEqual(1, service.CloseReCheckCallCount, "应调用 CloseReCheckSetTrade 1次");
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "应调用 SaveSwapDeal 1次");
Assert.AreEqual((int)SwapEventTypeEnum., service.SaveSwapDealCalls[0].eventType, "事件类型=平仓");
Assert.AreEqual(6000m, service.SaveSwapDealCalls[0].data.SwapRealizedPnL, 0.001m,
"SwapRealizedPnL 应=SwapCloseAmount(6000)");
Console.WriteLine($"UW_006: CloseReCheck={service.CloseReCheckCallCount}次, SwapRealizedPnL={service.SaveSwapDealCalls[0].data.SwapRealizedPnL} ✅");
}
}
}
@@ -0,0 +1,127 @@
using Newtonsoft.Json;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// SwapDealService 的可测试化子类(共享 stub)。
/// 继承 SwapDealServiceoverride seam 把 DB/事务/外部服务替换为内存收集器。
/// 被 SwapUnwindScenarioTest / SwapIncomeScenarioTest 共用,避免重复。
/// </summary>
public class TestableSwapDealService : SwapDealService
{
private readonly trade _trade;
private readonly Dictionary<int, swap_event> _swapEvents;
private readonly Dictionary<long, List<swap_flow_event>> _flowEventsByEventId;
/// <summary>捕获 AddClientCash 的每次调用(金额, 操作, 日期)</summary>
public List<(double amount, string action, DateTime date)> ClientCashCalls { get; } = new();
/// <summary>捕获 SaveSwapDeal 的每次调用(unwindData, eventType, clientCashId</summary>
public List<(UnwindData data, int eventType, int clientCashId)> SaveSwapDealCalls { get; } = new();
public int SaveAllChangesCount;
public int CloseReCheckCallCount;
public TestableSwapDealService(trade td,
Dictionary<int, swap_event> swapEvents = null,
Dictionary<long, List<swap_flow_event>> flowEventsByEventId = null)
: base(new OptUserInfo(0, nameof(TestableSwapDealService), 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() { } // 空操作
}
/// <summary>
/// SwapDealService 测试的共享工厂方法(TestableSwapDealService + UnwindData 构造)。
/// 被 SwapUnwindScenarioTest / SwapIncomeScenarioTest 共用。
/// </summary>
public static class SwapDealTestFactory
{
public const int SwapTradeId = 7700;
public static readonly DateTime ValueDate = new(2026, 6, 15);
public static readonly DateTime UnwindDate = new(2026, 6, 16);
public 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>
public 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)
};
}
public static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "")
{
Assert.IsTrue(Math.Abs(expected - actual) <= tolerance,
$"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}");
}
}
}