- 添加保证金返息去重机制,防止平仓时返息被重复计入资金记录 - 新增 GetMainCashRealizedPnL 方法专门处理返息剔除逻辑 - 更新 DealUnwind 和 ApproveSwapTrade 方法使用统一的返息处理口径 - 修改 ReleaseMarginByFundTag 方法增强标签分流功能 - 添加多个单元测试验证返息处理的正确性 - 将 SaveAllChanges 设为可测试化接缝便于测试验证 - 调整 FundTag 分流逻辑支持授信和现金腿分别处理
791 lines
41 KiB
C#
791 lines
41 KiB
C#
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
|
||
{
|
||
// ================================================================
|
||
// 场景1:SwapUnwind 全平仓 —— 持仓归零、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} ✅");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景2:SwapUnwind 部分平仓 —— HasPartialUnWind=1,TradeStatus 不变
|
||
// ================================================================
|
||
|
||
[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} ✅");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景3:SwapUnwind 含预付金 —— 两条资金流水
|
||
// ================================================================
|
||
|
||
[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);
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景4:DealFloatPosition 含费价重算(后端唯二真做计算的地方)
|
||
// ================================================================
|
||
|
||
/// <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} ✅");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景5:ApproveSwapTrade 审核通过全平仓 —— 反序列化事件并记账
|
||
// ================================================================
|
||
|
||
[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.系统操作_应付预付金),
|
||
"互换审批不应生成应付预付金流水");
|
||
}
|
||
|
||
// ================================================================
|
||
// 场景6:ApplySwapTrade 提交审核 —— 前置校验与保存事件
|
||
// ================================================================
|
||
|
||
[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);
|
||
}
|
||
}
|
||
}
|