fix(swap): 结算或全部平仓后,待实现置0

- 添加 NormalizeSettledInterestAmounts 方法处理手工平仓和互换的利息事件精度
- 添加 NormalizeManualSettlementAmounts 方法在手工结算前收敛流水金额
- 将利息计算精度从 ConsGlobal.PriceRound 统一调整为 InterestCalculationPrecision(12位)
- 在日终持仓快照中添加精度标准化处理,利息腿保留12位精度
- 修复全量平仓时待实现利息和费用的清零逻辑
- 在收益结算事件后清空待实现利息余额避免重复计算
- 更新单元测试验证现金与两位利息事件的一致性
This commit is contained in:
张名锐
2026-07-29 14:42:03 +08:00
parent bff3e920ce
commit 3d73835be5
5 changed files with 176 additions and 19 deletions
@@ -258,5 +258,36 @@ namespace YLErp.Modules.SwapModule
Assert.AreEqual(500000.00m, savedData.CloseNotionalValue, "平仓名义本金应按两位小数写入事件");
Assert.AreEqual(500000.01, td.StockEqvNotional, 0.000001, "trade 剩余名义本金应在扣减后舍入两位小数");
}
[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);
}
}
}
+67 -13
View File
@@ -47,6 +47,51 @@ namespace YLErp.Modules.SwapModule
unwindData.CloseNotionalValue = Math.Round(unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
// 待实现利息会进入 decimal(30,12) 日终快照
private const int InterestCalculationPrecision = 12;
/// <summary>
/// 手工平仓、手工互换及收益结算的利息事件按金额两位落库。
/// 自动平仓保留原有计算与落库口径,不适用本阶段的手工结算规则。
/// </summary>
private static bool NormalizeSettledInterestAmounts(IEnumerable<swap_flow_event> flowEvents, int eventType, string eventReason)
{
if ((eventType != (int)SwapEventTypeEnum. && eventType != (int)SwapEventTypeEnum.)
|| eventReason == "系统操作_自动平仓")
{
return false;
}
foreach (var flowEvent in flowEvents.Where(x => string.IsNullOrEmpty(x.UnderlyingCode)))
{
// 只处理利息腿;浮动腿损益在日终快照入口统一按两位落库。
flowEvent.InterestPrincipal = Math.Round(flowEvent.InterestPrincipal, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
flowEvent.InterestAmount = Math.Round(flowEvent.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
flowEvent.TdInterestAmount = Math.Round(flowEvent.TdInterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
flowEvent.InterestClosePnL = Math.Round(flowEvent.InterestClosePnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
flowEvent.InterestFee = Math.Round(flowEvent.InterestFee, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
return true;
}
// 客户现金在 SaveSwapDeal 之前创建,手工结算必须先收敛流水并重算汇总金额。
private void NormalizeManualSettlementAmounts(UnwindData unwindData, int eventType, string eventReason)
{
if (!NormalizeSettledInterestAmounts(unwindData.FlowEvents, eventType, eventReason))
{
return;
}
if (unwindData.FlowEvents.Any(x => !string.IsNullOrEmpty(x.UnderlyingCode)))
{
CalcCloseAmount(unwindData);
return;
}
unwindData.SwapCloseAmount = Math.Round(unwindData.SwapCloseAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
unwindData.SwapRealizedPnL = Math.Round(unwindData.SwapRealizedPnL, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
/// <summary>保存所有变更(生产: DbContext.SaveChanges;测试: 空操作)</summary>
protected virtual void SaveAllChanges()
{
@@ -846,8 +891,8 @@ namespace YLErp.Modules.SwapModule
}
// 四舍五入并赋值
interest.InterestAmount = Math.Round(interestAmount, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
interest.TdInterestAmount = Math.Round(tdInterestAmount, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
interest.InterestAmount = Math.Round(interestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.TdInterestAmount = Math.Round(tdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
// 计算InterestClosePnL(方向:收取=1为正,支付=-1为负)
var interestRatio = position.InterestDirection == 1 ? 1m : -1m;
interest.InterestClosePnL = interest.InterestAmount * interestRatio;
@@ -962,8 +1007,8 @@ namespace YLErp.Modules.SwapModule
CalcDailySimpleInterest(preEodPosition, endDate, position, posiNotionalValue, interest, annualDays, needPrice, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount);
}
interest.InterestAmount = Math.Round(InterestAmount, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
interest.TdInterestAmount = Math.Round(TdInterestAmount, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
interest.InterestAmount = Math.Round(InterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.TdInterestAmount = Math.Round(TdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
interest.InterestClosePnL = interest.InterestAmount * interestRatio;
}
if (add)
@@ -1047,8 +1092,8 @@ namespace YLErp.Modules.SwapModule
// consumedInterest 为绝对值口径(swap_flow_event.InterestAmount 之和),与 interest 口径一致。
interest -= consumedInterest;
tdinterest -= consumedInterest;
InterestAmount = Math.Round(interest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
TdInterestAmount = Math.Round(tdinterest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
InterestAmount = Math.Round(interest, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
TdInterestAmount = Math.Round(tdinterest, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
}
/// <summary>
@@ -1108,8 +1153,8 @@ namespace YLErp.Modules.SwapModule
tdinterest += tdinterest1;
}
}
InterestAmount = Math.Round(interest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
TdInterestAmount = Math.Round(tdinterest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
InterestAmount = Math.Round(interest, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
TdInterestAmount = Math.Round(tdinterest, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
}
/// <summary>
@@ -1178,8 +1223,8 @@ namespace YLErp.Modules.SwapModule
tdinterest = tdinterest1;
}
flowEvent.FloatRate = Convert.ToDecimal(floatRate);
InterestAmount = Math.Round(interest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
TdInterestAmount = Math.Round(tdinterest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
InterestAmount = Math.Round(interest, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
TdInterestAmount = Math.Round(tdinterest, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
}
/// <summary>
@@ -1231,8 +1276,8 @@ namespace YLErp.Modules.SwapModule
tdinterest /= annualDays;
}
InterestAmount = Math.Round(interestProfitSum+interest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
TdInterestAmount = Math.Round(tdinterest, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
InterestAmount = Math.Round(interestProfitSum + interest, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
TdInterestAmount = Math.Round(tdinterest, InterestCalculationPrecision, MidpointRounding.AwayFromZero);
}
/// <summary>
@@ -1248,6 +1293,7 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
NormalizeNotionalValues(unwindData);
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_平仓");
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
ValidateFrontendPnL(unwindData, isIncome: false); // 只读校验告警,不阻断交易
// 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。
@@ -1651,6 +1697,7 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount;
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_平仓");
var trans = DbContext.Database.BeginTransaction();
try
{
@@ -1694,6 +1741,7 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount;
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_互换");
var trans = DbContext.Database.BeginTransaction();
try
{
@@ -1733,6 +1781,7 @@ namespace YLErp.Modules.SwapModule
}
NormalizeIncomeUnwindDate(unwindData);
ValidateIncomeValueDate(unwindData, td);
NormalizeManualSettlementAmounts(unwindData, (int)SwapEventTypeEnum., "系统操作_互换");
//CheckLastEod(unwindData.ValueDate, td.StartDate.Value, unwindData.SwapTradeId); //去掉平仓收盘限制
ValidateFrontendPnL(unwindData, isIncome: true); // 只读校验告警,不阻断交易
ExecuteInTransaction(() =>
@@ -1825,6 +1874,7 @@ namespace YLErp.Modules.SwapModule
ValidateIncomeValueDate(unwindData, td);
}
unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount;
NormalizeManualSettlementAmounts(unwindData, eventType, eventType == (int)SwapEventTypeEnum. ? "系统操作_互换" : "系统操作_平仓");
// 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。
// 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。
// 与 SwapUnwind(L1270) 保持一致——缺少此转换会导致 SaveSwapDealInternal 的 B→A 还原出错
@@ -1878,6 +1928,7 @@ namespace YLErp.Modules.SwapModule
throw new ServiceException("未找到交易信息");
}
var flowList = new List<swap_flow_event>(unwindData.FlowEvents);
NormalizeSettledInterestAmounts(flowList, eventType, eventResason);
unwindData.FlowEvents.Clear();
// 落库展示用"占期初(original)"语义(A);计算链(费用递减/全平判定)用"占剩余(remaining)"语义(B)。
// 序列化前把 ClosePercent 还原为 A,序列化后立即还原回 B 供后续使用。
@@ -1977,7 +2028,10 @@ namespace YLErp.Modules.SwapModule
position.InterestFeePending += interest.InterestFee;
if ((interest.InterestMode == (int)InterestModeEnum. || interest.InterestMode == (int)InterestModeEnum.) && eventType == (int)SwapEventTypeEnum.)
{
position.InterestPrincipalFix -= interest.InterestPrincipal;
position.InterestPrincipalFix = Math.Round(
position.InterestPrincipalFix - interest.InterestPrincipal,
ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
}
}
}
@@ -47,11 +47,72 @@ namespace YLErp.Modules.SwapModule
: ConsGlobal.SwapDeliveryPriceRound;
}
// 日终利息待实现需跨日累计,按表设计保留 12 位;已实现结算仍按金额两位处理。
private const int EodInterestStoragePrecision = 12;
private static decimal RoundMoney(decimal value)
{
return Math.Round(value, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
private static decimal RoundEodInterest(decimal value)
{
return Math.Round(value, EodInterestStoragePrecision, MidpointRounding.AwayFromZero);
}
/// <summary>
/// 仅在写入 eod_swap_position 前统一快照精度。
/// 浮动腿收益最终以金额两位展示和存储;利息腿的待实现、计息基数及利率保留 12 位,
/// 使部分结算后的尾差可继续参与后续计息。
/// </summary>
private static void NormalizeEodPositionForStorage(eod_swap_position position)
{
if (string.IsNullOrEmpty(position.UnderlyingCode))
{
// 利息腿没有标的代码:待实现字段保留高精度,已实现结算字段收敛到金额两位。
position.InterestPrincipalFix = RoundEodInterest(position.InterestPrincipalFix);
position.InterestRateDefault = RoundEodInterest(position.InterestRateDefault);
position.InterestFeePending = RoundEodInterest(position.InterestFeePending);
position.TdInterestPrincipal = RoundEodInterest(position.TdInterestPrincipal);
position.TdInterestRate = RoundEodInterest(position.TdInterestRate);
position.TdInterestIncome = RoundEodInterest(position.TdInterestIncome);
position.TdInterestFee = RoundEodInterest(position.TdInterestFee);
position.InterestIncomeSum = RoundEodInterest(position.InterestIncomeSum);
position.InterestFeeSum = RoundEodInterest(position.InterestFeeSum);
position.InterestProfitSum = RoundEodInterest(position.InterestProfitSum);
position.FloatRate = RoundEodInterest(position.FloatRate);
position.SwapPositionValue = RoundEodInterest(position.SwapPositionValue);
position.TdCloseInterest = RoundMoney(position.TdCloseInterest);
position.TdCloseInterestFee = RoundMoney(position.TdCloseInterestFee);
position.RealizedInterest = RoundMoney(position.RealizedInterest);
position.RealizedInterestFee = RoundMoney(position.RealizedInterestFee);
}
else
{
// 浮动腿有标的代码:其损益作为金额结果落库,统一按两位四舍五入。
position.TdPosiDividend = RoundMoney(position.TdPosiDividend);
position.PosiMtmPnL = RoundMoney(position.PosiMtmPnL);
position.PosiDividendSum = RoundMoney(position.PosiDividendSum);
position.PosiFeePending = RoundMoney(position.PosiFeePending);
position.PosiProfitSum = RoundMoney(position.PosiProfitSum);
position.TdCloseMtmPnl = RoundMoney(position.TdCloseMtmPnl);
position.TdCloseDividend = RoundMoney(position.TdCloseDividend);
position.TdCloseFee = RoundMoney(position.TdCloseFee);
position.RealizedMtmPnL = RoundMoney(position.RealizedMtmPnL);
position.RealizedDividend = RoundMoney(position.RealizedDividend);
position.RealizedFee = RoundMoney(position.RealizedFee);
position.SwapPositionValue = RoundMoney(position.SwapPositionValue);
}
position.RealizedPnl = RoundMoney(position.RealizedPnl);
}
#region Seamsoverride DB/
/// <summary>持久化 eod 持仓记录(生产: DbContext.Add;测试: 收集到列表)</summary>
protected virtual void PersistEodSwapPosition(eod_swap_position position)
{
// 所有新增或更新的日终持仓都经过此入口,避免不同日终分支出现精度差异。
NormalizeEodPositionForStorage(position);
var storagePriceRound = GetStorageDeliveryPriceRound(position.UnderlyingInstrumentType, position.UnderlyingCode);
position.PosiGrossPrice = Math.Round(position.PosiGrossPrice, storagePriceRound, MidpointRounding.AwayFromZero);
position.UnderlyingPrice = Math.Round(position.UnderlyingPrice, storagePriceRound, MidpointRounding.AwayFromZero);
@@ -1017,8 +1078,17 @@ namespace YLErp.Modules.SwapModule
intersetAcmount /= tradeExtend.AnnualDays;
}
newEodPayPosition.TdInterestIncome = intersetAcmount;// 要算一下当天产生的利息
newEodPayPosition.InterestIncomeSum = eodPayPosition.InterestIncomeSum + newEodPayPosition.TdInterestIncome - newEodPayPosition.TdCloseInterest; //上一天待实现 + 当天产生的利息 - flowEvents的利息
newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee;
if (valueDate >= td.ExerciseDate.Value)
{
// 当前分支已有最终收益结算事件:该事件已包含待实现余额并按两位结出,快照不再留存尾差。
newEodPayPosition.InterestIncomeSum = 0;
newEodPayPosition.InterestFeeSum = 0;
}
else
{
newEodPayPosition.InterestIncomeSum = eodPayPosition.InterestIncomeSum + newEodPayPosition.TdInterestIncome - newEodPayPosition.TdCloseInterest;
newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee;
}
newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum;
//持仓价值
newEodPayPosition.SwapPositionValue = newEodPayPosition.InterestProfitSum * ratio + newEodPayPosition.PosiProfitSum;
@@ -1277,14 +1347,16 @@ namespace YLErp.Modules.SwapModule
$",TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}");
if (closePercent == 1)
{
// 全量平仓后不应把待实现利息或费用带入下一交易日。
newEodPayPosition.InterestIncomeSum = 0;
newEodPayPosition.InterestFeeSum = 0;
}
else
{
newEodPayPosition.InterestIncomeSum = InterestAmount;
newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee;
}
//持仓内容-利息腿-损益统计(本方视角)
newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee;
newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum;
//持仓价值
newEodPayPosition.SwapPositionValue = newEodPayPosition.InterestProfitSum * ratio + newEodPayPosition.PosiProfitSum;
@@ -460,7 +460,7 @@ namespace YLErp.Modules.SwapModule
flow_Event.InterestDirection = position.InterestDirection;
flow_Event.InterestRate = position.InterestRateDefault;
flow_Event.InterestPrincipal = position.InterestPrincipalFix;
flow_Event.InterestPrincipal = Math.Round(position.InterestPrincipalFix, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
flow_Event.InterestSwapInterval = position.InterestSwapInterval;
flow_Event.InterestMode = position.InterestMode;
flow_Event.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
@@ -1382,7 +1382,7 @@ namespace YLErp.Modules.SwapModule
position.InterestDirection = swap.InterestDirection;
position.InterestMode = swap.InterestMode;
position.InterestRateDefault = swap.InterestRateDefault;
position.InterestPrincipalFix = swap.InterestPrincipalFix;
position.InterestPrincipalFix = Math.Round(swap.InterestPrincipalFix, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
position.InterestSwapInterval = swap.InterestSwapInterval;
position.PosiStartDate = td.StartDate.Value;
position.PosiMatuirityDate = td.ExerciseDate.Value;
@@ -1656,7 +1656,7 @@ namespace YLErp.Modules.SwapModule
{
posi.InterestAmount = eodPosi.RealizedInterest;
posi.InterestFeePending = eodPosi.InterestFeePending;
posi.InterestPrincipalFix= eodPosi.InterestPrincipalFix;
posi.InterestPrincipalFix = Math.Round(eodPosi.InterestPrincipalFix, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
posi.PosiTradingFeePending = eodPosi.PosiFeePending;
posi.PosiDividendIncome = eodPosi.PosiDividendSum;
posi.PosiQuantity = eodPosi.PosiQuantity;