fix(swap): 解决互换交易全额平仓计算和判定问题

- 在前端计算函数中添加全额平仓时返回剩余数量的逻辑
- 添加针对 trade2308 问题的单元测试验证全额平仓场景
- 实现后端全额平仓请求标准化处理方法 NormalizeFullCloseRequest
- 添加重新计算标准化平仓金额的方法 RecalculateNormalizedUnwindAmounts
- 实现平仓后是否全额关闭的判定逻辑 IsFullCloseAfterDeduction
- 在平仓流程中集成全额平仓标准化处理和金额重新计算
- 更新平仓后交易状态和剩余金额数量的处理逻辑
- 完善审批流程中的平仓数据标准化和流动事件处理
- 优化持仓扣减时全额平仓的处理逻辑
- 添加多个单元测试验证各种全额平仓场景的正确性
This commit is contained in:
张名锐
2026-08-06 13:24:41 +08:00
parent c7900adca1
commit 6bbe9b1c13
4 changed files with 363 additions and 15 deletions
@@ -197,6 +197,8 @@ namespace YLErp.Modules.SwapModule
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,
@@ -259,6 +261,33 @@ namespace YLErp.Modules.SwapModule
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_现金与两位利息事件保持一致()
{
@@ -289,5 +318,183 @@ namespace YLErp.Modules.SwapModule
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);
}
}
}
+142 -15
View File
@@ -47,6 +47,65 @@ namespace YLErp.Modules.SwapModule
unwindData.CloseNotionalValue = Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
private static bool NormalizeFullCloseRequest(UnwindData unwindData)
{
if (unwindData.CloseMethod != (int)CloseMethodEnum.
&& unwindData.ClosePercent < 1
&& !(unwindData.PositionQty > 0 && unwindData.CloseQty >= unwindData.PositionQty)
&& !(unwindData.PosiNotionalValue > 0 && unwindData.CloseNotionalValue >= unwindData.PosiNotionalValue))
{
return false;
}
var closeQty = unwindData.CloseQty;
var closeNotionalValue = unwindData.CloseNotionalValue;
unwindData.ClosePercent = 1;
if (unwindData.PositionQty > 0) unwindData.CloseQty = unwindData.PositionQty;
if (unwindData.PosiNotionalValue > 0) unwindData.CloseNotionalValue = unwindData.PosiNotionalValue;
return closeQty != unwindData.CloseQty || closeNotionalValue != unwindData.CloseNotionalValue;
}
private static void RecalculateNormalizedUnwindAmounts(UnwindData unwindData)
{
var floatLeg = unwindData.FlowEvents.FirstOrDefault(x => !string.IsNullOrEmpty(x.UnderlyingCode));
if (floatLeg == null || floatLeg.PosiGrossPrice == 0) return;
var input = new UnwindInput
{
Multiplier = ConsGlobal.InstrumentType.IsBond(floatLeg.UnderlyingInstrumentType) ? 100 : 1,
PosiGrossPrice = floatLeg.PosiGrossPrice,
TradingAmountAvg = floatLeg.TradingAmountAvg,
CloseQty = unwindData.CloseQty,
PositionQty = unwindData.PositionQty,
ContractSize = floatLeg.ContractSize,
CloseNotionalValue = unwindData.CloseNotionalValue,
PayDirection = floatLeg.PayDirection,
PositionType = floatLeg.PositionType,
TradingFee = floatLeg.TradingFee.ToString(),
TradingFeePending = floatLeg.TradingFeePending.ToString(),
DividendIn = floatLeg.DividendIn.ToString()
};
foreach (var leg in unwindData.FlowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)))
{
var target = leg.InterestMode == (int)InterestModeEnum.
|| leg.InterestMode == (int)InterestModeEnum.
? input.MarginLegs
: input.InterestLegs;
target.Add(new LegInput { InterestClosePnL = leg.InterestClosePnL });
}
var result = FrontendCalcReference.CalcUnwind(input);
floatLeg.MarkClosePnl = result.MarkClosePnl;
unwindData.SwapCloseAmount = result.SwapCloseAmount;
unwindData.SwapRealizedPnL = result.SwapRealizedPnL;
unwindData.SwapMarginRebatePnl = result.SwapMarginRebatePnl;
}
private static bool IsFullCloseAfterDeduction(UnwindData unwindData, double remainingNotional, double remainingQuantity)
{
return unwindData.ClosePercent == 1 || (remainingNotional == 0 && remainingQuantity == 0);
}
// 待实现利息会进入 decimal(30,12) 日终快照
private const int InterestCalculationPrecision = 12;
@@ -1335,10 +1394,14 @@ namespace YLErp.Modules.SwapModule
NormalizeNotionalValues(unwindData);
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_平仓");
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易
// 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。
// 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。
unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue);
if (NormalizeFullCloseRequest(unwindData))
{
RecalculateNormalizedUnwindAmounts(unwindData);
}
ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易
bool cofirm = false;
ExecuteInTransaction(() =>
{
@@ -1354,18 +1417,24 @@ namespace YLErp.Modules.SwapModule
DealFloatPosition(unwindData);
var flowList = new List<swap_flow_event>(unwindData.FlowEvents);
var eventId = SaveSwapDeal(unwindData, (int)SwapEventTypeEnum., clientCashId, "系统操作_平仓");
if (unwindData.CloseMethod == (int)CloseMethodEnum. || unwindData.ClosePercent == 1)
var remainingStockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
var remainingTradeAmount = td.TradeAmount - Convert.ToDouble(unwindData.CloseQty);
var isFullClose = IsFullCloseAfterDeduction(unwindData, remainingStockEqvNotional, remainingTradeAmount);
if (isFullClose)
{
td.TradeStatus = "已平仓";
td.StockEqvNotional = 0;
td.TradeAmount = 0;
CallSaveSwapTradeClientCash(td, unwindData.ValueDate);
}
else
{
td.HasPartialUnWind = 1;
td.StockEqvNotional = remainingStockEqvNotional;
td.TradeAmount = remainingTradeAmount;
}
td.Notional = td.TradeAmount;
td.UnWindDate = unwindData.UnwindDate;
td.StockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
td.TradeAmount -= Convert.ToDouble(unwindData.CloseQty);
SaveAllChanges();
cofirm = true;
});
@@ -1860,12 +1929,36 @@ namespace YLErp.Modules.SwapModule
throw new Exception("该笔交易状态为平仓待复核,未找到相关记录,请检查该笔交易是否有效");
}
swapEvent.unwindData = JsonConvert.DeserializeObject<UnwindData>(swapEvent.EventData);
NormalizeNotionalValues(swapEvent.unwindData);
// Stored events keep display ratio A; approval calculations consume remaining ratio B.
swapEvent.unwindData.ClosePercent = ToRemainingClosePercent(
swapEvent.unwindData.ClosePercent,
swapEvent.unwindData.NotionalValue,
swapEvent.unwindData.PosiNotionalValue);
var flowList = FindFlowEventsByEventId(swapEvent.id);
swapEvent.unwindData.FlowEvents = flowList;
if (eventType == (int)SwapEventTypeEnum.)
{
if (NormalizeFullCloseRequest(swapEvent.unwindData))
{
RecalculateNormalizedUnwindAmounts(swapEvent.unwindData);
}
}
if (eventType == (int)SwapEventTypeEnum.)
{
NormalizeIncomeUnwindDate(swapEvent.unwindData);
ValidateIncomeValueDate(swapEvent.unwindData, td);
}
var flowList = FindFlowEventsByEventId(swapEvent.id);
if (eventType == (int)SwapEventTypeEnum.)
{
foreach (var item in flowList.Where(x => x.PositionType > 0))
{
item.Quantity = swapEvent.unwindData.CloseQty;
item.PositionQty = swapEvent.unwindData.ClosePercent == 1
? 0
: swapEvent.unwindData.PositionQty - swapEvent.unwindData.CloseQty;
}
}
string action = eventType == (int)SwapEventTypeEnum. ? ClientCashInCashOut._互换 : ClientCashInCashOut._平仓费;
int clientCashId = AddClientCash(td, Convert.ToDouble(-swapEvent.unwindData.SwapRealizedPnL), action, swapEvent.unwindData.ValueDate);
if (swapEvent.unwindData.SwapMarginAmount != 0)
@@ -1873,7 +1966,28 @@ namespace YLErp.Modules.SwapModule
AddClientCash(td, Convert.ToDouble(swapEvent.unwindData.SwapMarginAmount), ClientCashInCashOut._应付预付金, swapEvent.unwindData.ValueDate);
}
swapEvent.ClientCashId = clientCashId;
if (swapEvent.unwindData.CloseMethod == (int)CloseMethodEnum.)
td.UnWindDate = swapEvent.unwindData.UnwindDate;
if (eventType != (int)SwapEventTypeEnum.)
{
var remainingStockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(swapEvent.unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
var remainingTradeAmount = td.TradeAmount - Convert.ToDouble(swapEvent.unwindData.CloseQty);
var isFullClose = IsFullCloseAfterDeduction(swapEvent.unwindData, remainingStockEqvNotional, remainingTradeAmount);
if (isFullClose)
{
td.TradeStatus = "已平仓";
td.StockEqvNotional = 0;
td.TradeAmount = 0;
CallSaveSwapTradeClientCash(td, swapEvent.unwindData.ValueDate);
}
else
{
td.TradeStatus = ConsTrade.;
td.HasPartialUnWind = 1;
td.StockEqvNotional = remainingStockEqvNotional;
td.TradeAmount = remainingTradeAmount;
}
}
else if (swapEvent.unwindData.CloseMethod == (int)CloseMethodEnum.)
{
td.TradeStatus = "已平仓";
CallSaveSwapTradeClientCash(td, swapEvent.unwindData.ValueDate);
@@ -1883,12 +1997,6 @@ namespace YLErp.Modules.SwapModule
td.TradeStatus = ConsTrade.;
td.HasPartialUnWind = 1;
}
td.UnWindDate = swapEvent.unwindData.UnwindDate;
if (eventType != (int)SwapEventTypeEnum.)
{
td.StockEqvNotional = Math.Round(td.StockEqvNotional - Convert.ToDouble(swapEvent.unwindData.CloseNotionalValue), ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
td.TradeAmount -= Convert.ToDouble(swapEvent.unwindData.CloseQty);
}
td.Notional = td.TradeAmount;
UpdateInitalPosition(flowList, swapEvent.unwindData, eventType);
@@ -1920,6 +2028,13 @@ namespace YLErp.Modules.SwapModule
// 与 SwapUnwind(L1270) 保持一致——缺少此转换会导致 SaveSwapDealInternal 的 B→A 还原出错
// (例如第二次部分平仓 50%(A) → 错误还原为 0.325 而非 0.50)。
unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue);
if (eventType == (int)SwapEventTypeEnum.)
{
if (NormalizeFullCloseRequest(unwindData))
{
RecalculateNormalizedUnwindAmounts(unwindData);
}
}
string action = eventType == (int)SwapEventTypeEnum. ? ClientCashInCashOut._互换 : ClientCashInCashOut._平仓费;
ExecuteInTransaction(() =>
{
@@ -2053,8 +2168,17 @@ namespace YLErp.Modules.SwapModule
else
{
// 平仓时才扣减持仓
position.PosiQuantity -= unwindData.CloseQty;
position.PosiNotionalValue = Math.Round(position.PosiNotionalValue - unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
var remainingPositionQty = position.PosiQuantity - unwindData.CloseQty;
var remainingPositionNotional = Math.Round(
position.PosiNotionalValue - unwindData.CloseNotionalValue,
ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
position.PosiQuantity = unwindData.ClosePercent == 1
? 0
: remainingPositionQty;
position.PosiNotionalValue = unwindData.ClosePercent == 1
? 0
: remainingPositionNotional;
position.PosiTradingFee -= position.PosiTradingFee * unwindData.ClosePercent;
position.PosiTradingFeePending -= position.PosiTradingFeePending * unwindData.ClosePercent;
}
@@ -2068,10 +2192,13 @@ namespace YLErp.Modules.SwapModule
position.InterestFeePending += interest.InterestFee;
if ((interest.InterestMode == (int)InterestModeEnum. || interest.InterestMode == (int)InterestModeEnum.) && eventType == (int)SwapEventTypeEnum.)
{
position.InterestPrincipalFix = Math.Round(
var remainingInterestPrincipal = Math.Round(
position.InterestPrincipalFix - interest.InterestPrincipal,
ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
position.InterestPrincipalFix = unwindData.ClosePercent == 1
? 0
: remainingInterestPrincipal;
}
}
}
+13
View File
@@ -182,6 +182,19 @@ describe('多次部分平仓:全部↔部分切换 CloseQty 不跳变(占期
expectClose(closeQty, 25000000, '应=25000000 不受 JS 浮点偏差影响');
expect(closeQty).not.toBe(24999999.999999996);
});
test('trade2308: full close uses the remaining quantity after fixed6 percent formatting', () => {
const notionalValue = 9812312.31;
const posiNotionalValue = 4906156.15;
const oriClosePercent = posiNotionalValue / notionalValue;
const closePercent = 0.5;
const positionQty = 5000000;
const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(
closePercent, oriClosePercent, positionQty);
expect(closeQty).toBe(positionQty);
});
});
describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', () => {
@@ -94,6 +94,7 @@
function calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, positionQty) {
var ori = Number(oriClosePercent);
if (ori === 0) return 0;
if (Number(closePercent) >= ori) return Number(positionQty);
return roundHalfAwayFromZero(Number(positionQty) * (Number(closePercent) / ori), 2);
}