Files
zszq-trs/UnitTestProject/Modules/SwapModule/SwapUnwindScenarioTest.cs
张名锐 d2b6dff598 fix(swap): 解决互换平仓时保证金返息重复计算问题
- 添加保证金返息去重机制,防止平仓时返息被重复计入资金记录
- 新增 GetMainCashRealizedPnL 方法专门处理返息剔除逻辑
- 更新 DealUnwind 和 ApproveSwapTrade 方法使用统一的返息处理口径
- 修改 ReleaseMarginByFundTag 方法增强标签分流功能
- 添加多个单元测试验证返息处理的正确性
- 将 SaveAllChanges 设为可测试化接缝便于测试验证
- 调整 FundTag 分流逻辑支持授信和现金腿分别处理
2026-08-27 16:13:59 +08:00

791 lines
41 KiB
C#
Raw Permalink 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;
using YLErp.Modules.SwapModule.Margin;
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} ✅");
}
/// <summary>
/// 真实平仓返息口径:浮动端 -2.00 + 普通利息 986.30 + 预付金返息 -6.58 =
/// SwapRealizedPnL 977.72。主平仓资金必须剔除已独立记账的返息,
/// 即 -(977.72 - (-6.58)) = -984.30;返息记录为 -(-6.58) = +6.58
/// 两条现金合计仍为 -977.72。
/// </summary>
[TestMethod]
public void UW_003A_SwapUnwind_返息已含总盈亏_主记录剔除且独立记账()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 0m,
closeMethod: (int)CloseMethodEnum.全部平仓,
closePercent: 1m,
closeQty: 10000m,
closeNotionalValue: 1000000m,
positionQty: 10000m);
unwindData.FlowEvents.Add(new swap_flow_event
{
UnderlyingCode = "261031.IB",
MarkClosePnl = -2.00m
});
unwindData.FlowEvents.Add(new swap_flow_event
{
InterestMode = (int)InterestModeEnum.标的期初全价,
InterestClosePnL = 986.30m
});
unwindData.FlowEvents.Add(new swap_flow_event
{
InterestMode = (int)InterestModeEnum.初始预付金,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestClosePnL = -6.58m
});
service.SwapUnwind(unwindData);
Assert.AreEqual(977.72m, unwindData.SwapRealizedPnL, 0.001m,
"返息已包含在平仓总盈亏中");
Assert.AreEqual(-6.58m, unwindData.SwapMarginRebatePnl, 0.001m,
"预付金返息应从保证金腿汇总");
Assert.AreEqual(2, service.ClientCashCalls.Count,
"主平仓与预付金返息应分别记账");
Assert.AreEqual(-984.30, service.ClientCashCalls[0].amount, 0.001,
"主平仓资金应剔除已独立记账的预付金返息");
Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action);
Assert.AreEqual(6.58, service.ClientCashCalls[1].amount, 0.001,
"预付金返息资金记录应保留原方向");
Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[1].action);
Assert.AreEqual(-977.72, service.ClientCashCalls.Sum(x => x.amount), 0.001,
"两笔资金合计应等于原始总盈亏");
}
/// <summary>
/// R4 §2.4 FundTag 分流(平仓):SwapRealizedPnL=1000(含返息 200)、SwapMarginAmount=5000
/// 分流结果=现金本金 3000 / 授信本金 2000、现金返息 120 / 授信返息 80。
/// 主平仓费=-(1000-200)=-800(剔除完整返息,含授信部分);
/// 应付预付金=+3000(仅现金本金;授信 2000 走授信出入表"释放",不进资金流水);
/// 预付金返息=-120(仅现金部分;授信 80 不产生任何资金)。
/// 三笔现金合计=-800+3000-120=2080≠-1000+5000——差额正是授信部分
/// (本金 2000+返息 80),它们按 R4 §2.4 原路回授信而非现金,故不出现资金流水。
/// </summary>
[TestMethod]
public void UW_003B_SwapUnwind_FundTag分流_授信部分不产生资金流水()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td)
{
// 模拟两条预付金腿按 FundTag 分流的结果(生产由 ReleaseMarginByFundTag 按腿标签计算)
ReleaseMarginByFundTagResult = new UnwindTagSplit
{
CashMargin = 3000, CreditMargin = 2000,
CashRebate = 120, CreditRebate = 80,
Releases = { new TagRelease { PositionId = 101, Amount = 2000 } }
}
};
// 不放浮动腿:跳过 CalcCloseAmount 重算,工厂传入的汇总金额原样进入记账,
// 断言只聚焦资金流水的分流消费逻辑。
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 1000m, swapMarginRebatePnl: 200m, swapMarginAmount: 5000m,
closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 1m,
closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m);
unwindData.FlowEvents.Add(new swap_flow_event
{
InterestMode = (int)InterestModeEnum.初始预付金,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestPrincipal = 5000m,
InterestClosePnL = 200m
});
service.SwapUnwind(unwindData);
Assert.AreEqual(3, service.ClientCashCalls.Count,
"平仓费+应付预付金(现金部分)+预付金返息(现金部分) 共3条");
Assert.AreEqual(-800.0, service.ClientCashCalls[0].amount, 0.001,
"主平仓费剔除完整返息(含授信部分),只留 1000-200");
Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action);
Assert.AreEqual(3000.0, service.ClientCashCalls[1].amount, 0.001,
"应付预付金=现金部分本金3000;授信部分2000走释放,不进资金流水");
Assert.AreEqual(ClientCashInCashOut.系统操作_应付预付金, service.ClientCashCalls[1].action);
Assert.AreEqual(-120.0, service.ClientCashCalls[2].amount, 0.001,
"预付金返息=现金部分120;授信返息80不产生任何资金流水");
Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[2].action);
}
// ================================================================
// 场景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} ✅");
}
[TestMethod]
public void UW_005B_ApproveSwapTrade_平仓返息不重复记入主记录()
{
var td = SwapDealTestFactory.CreateTrade();
td.ExerciseDate = new DateTime(2026, 12, 31);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 977.72m,
swapMarginRebatePnl: -6.58m,
closeMethod: (int)CloseMethodEnum.全部平仓,
closePercent: 1m,
closeQty: 10000m,
closeNotionalValue: 1000000m,
positionQty: 10000m);
unwindData.FlowEvents.Add(new swap_flow_event
{
InterestMode = (int)InterestModeEnum.初始预付金,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestClosePnL = -6.58m
});
var swapEvent = new swap_event
{
id = 3,
SwapTradeId = SwapDealTestFactory.SwapTradeId,
EventType = (int)SwapEventTypeEnum.平仓,
Invalid = false,
EventData = JsonConvert.SerializeObject(unwindData)
};
var service = new TestableSwapDealService(td,
swapEvents: new Dictionary<int, swap_event>
{
[(int)SwapEventTypeEnum.平仓] = swapEvent
},
flowEventsByEventId: new Dictionary<long, List<swap_flow_event>>
{
[3] = unwindData.FlowEvents
});
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓);
Assert.AreEqual(2, service.ClientCashCalls.Count,
"审批平仓应分别记主平仓与预付金返息");
Assert.AreEqual(-984.30, service.ClientCashCalls[0].amount, 0.001,
"审批主平仓资金应使用 SwapRealizedPnL - SwapMarginRebatePnl");
Assert.AreEqual(ClientCashInCashOut.系统操作_平仓费, service.ClientCashCalls[0].action);
Assert.AreEqual(6.58, service.ClientCashCalls[1].amount, 0.001,
"审批路径应保留独立预付金返息记录");
Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[1].action);
Assert.AreEqual(-977.72, service.ClientCashCalls.Sum(x => x.amount), 0.001,
"审批两笔资金合计应等于原始总盈亏");
}
[TestMethod]
public void UW_005C_DealUnwind_平仓返息不重复记入主记录()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 977.72m,
swapMarginRebatePnl: -6.58m,
closeMethod: (int)CloseMethodEnum.部分平仓,
closePercent: 0.5m,
closeQty: 5000m,
closeNotionalValue: 500000m,
positionQty: 10000m);
unwindData.FlowEvents.Add(new swap_flow_event
{
InterestMode = (int)InterestModeEnum.初始预付金,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestClosePnL = -6.58m
});
service.DealUnwindForTest(unwindData);
Assert.AreEqual(2, service.ClientCashCalls.Count,
"内部 DealUnwind 应分别记主平仓与预付金返息");
Assert.AreEqual(-984.30, service.ClientCashCalls[0].amount, 0.001,
"内部 DealUnwind 主记录应剔除已独立记账的返息");
Assert.AreEqual(6.58, service.ClientCashCalls[1].amount, 0.001,
"内部 DealUnwind 应保留独立预付金返息记录");
Assert.AreEqual(-977.72, service.ClientCashCalls.Sum(x => x.amount), 0.001,
"内部 DealUnwind 两笔资金合计应等于原始总盈亏");
}
/// <summary>
/// 互换审批(ApproveSwapTrade 互换分支)返息口径与直投 SwapIncome 一致:
/// 冻结事件 SwapRealizedPnL=1000(含返息 200)→ 主互换资金=-(1000-200)=-800
/// 返息单独记"预付金返息"-200;即使事件里带 SwapMarginAmount=-10000 也不返还本金
/// (互换结息只结返息,marginAmount 传 0 且 returnMarginPrincipal=false)。
/// 修复前:互换审批只记一条 -1000(返息混在主记录),且无 FundTag 分流。
/// </summary>
[Ignore("本次返息去重仅覆盖平仓链路")]
[TestMethod]
public void UW_005D_ApproveSwapTrade_互换审核_返息剔除且独立记账不返还本金()
{
var td = SwapDealTestFactory.CreateTrade();
td.ExerciseDate = new DateTime(2026, 12, 31);
// SwapMarginAmount 故意给非零负值:证明互换分支不会把它当本金返还记账
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 1000m, swapMarginRebatePnl: 200m, swapMarginAmount: -10000m);
unwindData.FlowEvents.Add(new swap_flow_event
{
InterestMode = (int)InterestModeEnum.初始预付金,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestPrincipal = 10000m,
InterestClosePnL = 200m
});
var swapEvent = new swap_event
{
id = 4,
SwapTradeId = SwapDealTestFactory.SwapTradeId,
EventType = (int)SwapEventTypeEnum.互换,
Invalid = false,
EventData = JsonConvert.SerializeObject(unwindData)
};
var service = new TestableSwapDealService(td,
swapEvents: new Dictionary<int, swap_event>
{
[(int)SwapEventTypeEnum.互换] = swapEvent
},
flowEventsByEventId: new Dictionary<long, List<swap_flow_event>>
{
[4] = unwindData.FlowEvents
});
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.互换);
Assert.AreEqual(2, service.ClientCashCalls.Count,
"互换审批应分别记主互换与预付金返息(修复前仅1条且返息混入主记录)");
Assert.AreEqual(-800.0, service.ClientCashCalls[0].amount, 0.001,
"主互换资金应剔除已单独记账的返息:-(1000-200)");
Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action);
Assert.AreEqual(-200.0, service.ClientCashCalls[1].amount, 0.001,
"返息单独记预付金返息(默认全现金 stub:200 全额现金)");
Assert.AreEqual(ClientCashInCashOut.系统操作_预付金返息, service.ClientCashCalls[1].action);
Assert.IsFalse(service.ClientCashCalls.Any(x => x.action == ClientCashInCashOut.系统操作_应付预付金),
"互换审批不返还预付金本金(事件内 SwapMarginAmount=-10000 也不得记账)");
Assert.AreEqual(-1000.0, service.ClientCashCalls.Sum(x => x.amount), 0.001,
"两条资金合计仍等于原始总盈亏,返息只计一次");
CollectionAssert.AreEqual(new[] { false }, service.ReleaseMarginPrincipalFlags,
"互换审批 returnMarginPrincipal=false:本金不参与分流(不写应付预付金/授信释放)");
}
[TestMethod]
public void UW_005A_ApproveSwapTrade_互换审核_不返还预付金本金()
{
var td = SwapDealTestFactory.CreateTrade();
td.ExerciseDate = new DateTime(2026, 12, 31);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 100m, swapMarginAmount: -10000m);
var swapEvent = new swap_event
{
id = 2,
SwapTradeId = SwapDealTestFactory.SwapTradeId,
EventType = (int)SwapEventTypeEnum.互换,
Invalid = false,
EventData = JsonConvert.SerializeObject(unwindData)
};
var service = new TestableSwapDealService(td,
swapEvents: new Dictionary<int, swap_event>
{
[(int)SwapEventTypeEnum.互换] = swapEvent
});
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.互换);
Assert.AreEqual(1, service.ClientCashCalls.Count, "互换审批只应生成互换结算流水");
Assert.AreEqual(ClientCashInCashOut.系统操作_互换, service.ClientCashCalls[0].action);
Assert.IsFalse(service.ClientCashCalls.Any(x => x.action == ClientCashInCashOut.系统操作_应付预付金),
"互换审批不应生成应付预付金流水");
}
// ================================================================
// 场景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} ✅");
}
[TestMethod]
public void UW_014_事件日期与平仓日期强绑定()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(swapRealizedPnL: 0m);
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓);
Assert.AreEqual(unwindData.ValueDate, service.SaveSwapDealCalls[0].data.UnwindDate);
Assert.AreEqual(unwindData.ValueDate, unwindData.UnwindDate);
}
// ================================================================
// 场景7:前端传"占期初(A)"语义,后端入口转"占剩余(B)" —— 全平判定
// 原始名义本金 100M / 剩余 60M,前端传 A=0.6(平掉原始 60M = 剩余全部)
// B = A × Notional/Posi = 0.6 × 100/60 = 1.0 → 触发全平
// ================================================================
[TestMethod]
public void UW_007_SwapUnwind_占期初A转占剩余B_全平判定正确()
{
var td = SwapDealTestFactory.CreateTrade();
td.StockEqvNotional = 600000;
td.TradeAmount = 600000;
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.全部平仓, closePercent: 0.6m,
closeQty: 600000m, closeNotionalValue: 600000m, positionQty: 600000m);
unwindData.NotionalValue = 1000000m; // 期初名义本金
unwindData.PosiNotionalValue = 600000m; // 剩余名义本金
service.SwapUnwind(unwindData);
// 桩 SaveSwapDeal 收集的是转换后的 B(落库 A 还原在生产 SaveSwapDealInternal 中,桩跳过)
Assert.AreEqual(1.0m, service.SaveSwapDealCalls[0].data.ClosePercent, 0.0001m,
"入口 A=0.6 应转为 B=1.0(占剩余全平)");
Assert.AreEqual("已平仓", td.TradeStatus, "B==1 触发全平 TradeStatus=已平仓");
Console.WriteLine($"UW_007: A=0.6→B={service.SaveSwapDealCalls[0].data.ClosePercent}, TradeStatus={td.TradeStatus} ✅");
}
// ================================================================
// 场景8:占期初(A)转占剩余(B) —— 部分平仓
// 原始 100M / 剩余 60M,前端传 A=0.3(平掉原始 30M = 剩余的 50%)
// B = A × Notional/Posi = 0.3 × 100/60 = 0.5 → 部分平仓
// ================================================================
[TestMethod]
public void UW_008_SwapUnwind_占期初A转占剩余B_部分平仓正确()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.3m,
closeQty: 300000m, closeNotionalValue: 300000m, positionQty: 600000m);
unwindData.NotionalValue = 1000000m; // 期初名义本金
unwindData.PosiNotionalValue = 600000m; // 剩余名义本金
service.SwapUnwind(unwindData);
Assert.AreEqual(0.5m, service.SaveSwapDealCalls[0].data.ClosePercent, 0.0001m,
"入口 A=0.3 应转为 B=0.5(占剩余 50%");
Assert.AreEqual(1, td.HasPartialUnWind, "B≠1 应为部分平仓,设 HasPartialUnWind=1");
Assert.AreEqual("确认成交", td.TradeStatus, "部分平仓 TradeStatus 保持不变");
Console.WriteLine($"UW_008: A=0.3→B={service.SaveSwapDealCalls[0].data.ClosePercent}, HasPartialUnWind={td.HasPartialUnWind} ✅");
}
[TestMethod]
public void UW_009_SwapUnwind_名义本金写入前舍入两位小数()
{
var td = SwapDealTestFactory.CreateTrade();
td.StockEqvNotional = 1000000.006;
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 0m, closeMethod: (int)CloseMethodEnum.部分平仓, closePercent: 0.5m,
closeQty: 5000m, closeNotionalValue: 500000.004m, positionQty: 10000m);
unwindData.NotionalValue = 1000000.006m;
unwindData.PosiNotionalValue = 1000000.006m;
service.SwapUnwind(unwindData);
var savedData = service.SaveSwapDealCalls[0].data;
Assert.AreEqual(1000000.01m, savedData.NotionalValue, "期初名义本金应按两位小数写入事件");
Assert.AreEqual(1000000.01m, savedData.PosiNotionalValue, "剩余名义本金应按两位小数写入事件");
Assert.AreEqual(500000.00m, savedData.CloseNotionalValue, "平仓名义本金应按两位小数写入事件");
Assert.AreEqual(500000.01, td.StockEqvNotional, 0.000001, "trade 剩余名义本金应在扣减后舍入两位小数");
}
[TestMethod]
public void UW_013_SwapUnwind_合法零点零一剩余不应判定全平()
{
var td = SwapDealTestFactory.CreateTrade();
td.StockEqvNotional = 1000000.01;
td.TradeAmount = 10000.01;
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 0m,
closeMethod: (int)CloseMethodEnum.部分平仓,
closePercent: 1000000m / 1000000.01m,
closeQty: 10000m,
closeNotionalValue: 1000000m,
positionQty: 10000.01m);
unwindData.NotionalValue = 1000000.01m;
unwindData.PosiNotionalValue = 1000000.01m;
service.SwapUnwind(unwindData);
Assert.AreEqual("确认成交", td.TradeStatus,
"剩余名义本金和数量均为0.01时仍应保持部分平仓状态");
Assert.AreEqual(1, td.HasPartialUnWind,
"合法的0.01尾差不应被清零");
Assert.AreEqual(0.01, td.StockEqvNotional, 0.000001);
Assert.AreEqual(0.01, td.TradeAmount, 0.000001);
}
[TestMethod]
public void UW_010_SwapUnwind_现金与两位利息事件保持一致()
{
var td = SwapDealTestFactory.CreateTrade();
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 10.0049m, closeMethod: (int)CloseMethodEnum.全部平仓,
closePercent: 1m, closeQty: 10000m, closeNotionalValue: 1000000m, positionQty: 10000m);
var floatEvent = new swap_flow_event
{
UnderlyingCode = "UT-FLOAT", PositionType = (int)PositionTypeFlag.Long,
EventType = (int)SwapEventTypeEnum.平仓, PayDirection = 1, MarkClosePnl = 10m
};
var interestEvent = new swap_flow_event
{
PositionType = 0, InterestAmount = 0.0049m, TdInterestAmount = 0.0049m,
InterestClosePnL = 0.0049m, InterestFee = 0.0049m
};
unwindData.FlowEvents.Add(floatEvent);
unwindData.FlowEvents.Add(interestEvent);
service.SwapUnwind(unwindData);
Assert.AreEqual(0m, interestEvent.InterestAmount);
Assert.AreEqual(0m, interestEvent.TdInterestAmount);
Assert.AreEqual(0m, interestEvent.InterestClosePnL);
Assert.AreEqual(0m, interestEvent.InterestFee);
Assert.AreEqual(10m, unwindData.SwapRealizedPnL);
Assert.AreEqual(-10d, service.ClientCashCalls[0].amount, 0.001d);
}
[TestMethod]
public void UW_011_trade2308_full_close_uses_remaining_values_and_zeroes_tail()
{
var td = SwapDealTestFactory.CreateTrade();
td.StockEqvNotional = 4906156.15;
td.TradeAmount = 5000000;
td.Notional = 5000000;
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 0m,
closeMethod: (int)CloseMethodEnum.部分平仓,
closePercent: 0.5m,
closeQty: 5000000.01m,
closeNotionalValue: 4906156.15m,
positionQty: 5000000m);
unwindData.NotionalValue = 9812312.31m;
unwindData.PosiNotionalValue = 4906156.15m;
service.SwapUnwind(unwindData);
var saved = service.SaveSwapDealCalls[0].data;
Assert.AreEqual((int)CloseMethodEnum.部分平仓, saved.CloseMethod,
"CloseMethod 保留本次部分平仓意图,终态由扣减后的持仓事实决定");
Assert.AreEqual(5000000m, saved.CloseQty);
Assert.AreEqual(4906156.15m, saved.CloseNotionalValue);
Assert.AreEqual(0d, td.StockEqvNotional, 0.000001);
Assert.AreEqual(0d, td.TradeAmount, 0.000001);
Assert.AreEqual(0d, td.Notional, 0.000001);
Assert.AreEqual("已平仓", td.TradeStatus);
}
[TestMethod]
public void UW_014_full_close_quantity_normalization_recalculates_pnl_and_cash()
{
var td = SwapDealTestFactory.CreateTrade();
td.StockEqvNotional = 4906156.15;
td.TradeAmount = 5000000;
td.Notional = 5000000;
var service = new TestableSwapDealService(td);
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 50000000.10m,
closeMethod: (int)CloseMethodEnum.部分平仓,
closePercent: 0.5m,
closeQty: 5000000.01m,
closeNotionalValue: 4906156.15m,
positionQty: 5000000m);
unwindData.NotionalValue = 9812312.31m;
unwindData.PosiNotionalValue = 4906156.15m;
unwindData.SwapCloseAmount = 50000000.10m;
var floatEvent = new swap_flow_event
{
UnderlyingCode = "UT-FLOAT",
PositionType = (int)PositionTypeFlag.Long,
EventType = (int)SwapEventTypeEnum.平仓,
PayDirection = 1,
PosiGrossPrice = 1m,
TradingAmountAvg = 11m,
MarkClosePnl = 50000000.10m
};
unwindData.FlowEvents.Add(floatEvent);
service.SwapUnwind(unwindData);
Assert.AreEqual(5000000m, unwindData.CloseQty);
Assert.AreEqual(50000000m, floatEvent.MarkClosePnl);
Assert.AreEqual(50000000m, unwindData.SwapRealizedPnL);
Assert.AreEqual(50000000m, unwindData.SwapCloseAmount);
Assert.AreEqual(-50000000d, service.ClientCashCalls.Single().amount, 0.001d);
}
[TestMethod]
public void UW_012_approve_restores_A_to_B_and_normalizes_flow_for_full_close()
{
var td = SwapDealTestFactory.CreateTrade();
td.StockEqvNotional = 4906156.15;
td.TradeAmount = 5000000;
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 0m,
closeMethod: (int)CloseMethodEnum.部分平仓,
closePercent: 4906156.15m / 9812312.31m,
closeQty: 5000000.01m,
closeNotionalValue: 4906156.15m,
positionQty: 5000000m);
unwindData.NotionalValue = 9812312.31m;
unwindData.PosiNotionalValue = 4906156.15m;
var floatEvent = new swap_flow_event
{
EventId = 1,
UnderlyingCode = "261031.IB",
PositionType = (int)PositionTypeFlag.Long,
Quantity = 5000000.01m,
PositionQty = -0.01m
};
var swapEvent = new swap_event
{
id = 1,
SwapTradeId = SwapDealTestFactory.SwapTradeId,
EventType = (int)SwapEventTypeEnum.平仓,
Invalid = false,
EventData = JsonConvert.SerializeObject(unwindData)
};
var service = new TestableSwapDealService(td,
swapEvents: new Dictionary<int, swap_event>
{
[(int)SwapEventTypeEnum.平仓] = swapEvent
},
flowEventsByEventId: new Dictionary<long, List<swap_flow_event>>
{
[1] = new List<swap_flow_event> { floatEvent }
});
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓);
Assert.AreEqual(1m, swapEvent.unwindData.ClosePercent);
Assert.AreEqual((int)CloseMethodEnum.部分平仓, swapEvent.unwindData.CloseMethod,
"审批不应把部分平仓事件改写为全平意图");
Assert.AreEqual(5000000m, swapEvent.unwindData.CloseQty);
Assert.AreEqual(4906156.15m, swapEvent.unwindData.CloseNotionalValue);
Assert.AreEqual(5000000m, floatEvent.Quantity);
Assert.AreEqual(0m, floatEvent.PositionQty);
Assert.AreEqual("已平仓", td.TradeStatus);
Assert.AreEqual(0d, td.StockEqvNotional, 0.000001);
Assert.AreEqual(0d, td.TradeAmount, 0.000001);
}
[TestMethod]
public void UW_015_approve_full_close_recalculates_normalized_pnl_before_cash()
{
var td = SwapDealTestFactory.CreateTrade();
td.StockEqvNotional = 4906156.15;
td.TradeAmount = 5000000;
var unwindData = SwapDealTestFactory.CreateUnwindData(
swapRealizedPnL: 50000000.10m,
closeMethod: (int)CloseMethodEnum.部分平仓,
closePercent: 4906156.15m / 9812312.31m,
closeQty: 5000000.01m,
closeNotionalValue: 4906156.15m,
positionQty: 5000000m);
unwindData.NotionalValue = 9812312.31m;
unwindData.PosiNotionalValue = 4906156.15m;
unwindData.SwapCloseAmount = 50000000.10m;
var floatEvent = new swap_flow_event
{
EventId = 1,
UnderlyingCode = "261031.IB",
PositionType = (int)PositionTypeFlag.Long,
PayDirection = 1,
PosiGrossPrice = 1m,
TradingAmountAvg = 11m,
MarkClosePnl = 50000000.10m,
Quantity = 5000000.01m,
PositionQty = -0.01m
};
var swapEvent = new swap_event
{
id = 1,
SwapTradeId = SwapDealTestFactory.SwapTradeId,
EventType = (int)SwapEventTypeEnum.平仓,
Invalid = false,
EventData = JsonConvert.SerializeObject(unwindData)
};
var service = new TestableSwapDealService(td,
swapEvents: new Dictionary<int, swap_event>
{
[(int)SwapEventTypeEnum.平仓] = swapEvent
},
flowEventsByEventId: new Dictionary<long, List<swap_flow_event>>
{
[1] = new List<swap_flow_event> { floatEvent }
});
service.ApproveSwapTrade(td, (int)SwapEventTypeEnum.平仓);
Assert.AreEqual(5000000m, swapEvent.unwindData.CloseQty);
Assert.AreEqual(50000000m, swapEvent.unwindData.SwapRealizedPnL);
Assert.AreEqual(-50000000d, service.ClientCashCalls.Single().amount, 0.001d);
}
}
}