fix(swap): 修复利息互换平仓结算中的尾差处理和本金计算问题

- 修复ExecuteSaveAutoEodInterestPosition方法参数传递,添加lastEodSwap、posiLongNotional和orginPv参数
- 修正平仓利息计算逻辑,区分手动结算和平仓后自动结算的利息处理
- 新增ResolveUnwindPreviousNotional静态方法,优化平仓前本金计算逻辑
- 修复部分平仓后利息累积边界问题,确保仅从上次EOD快照后开始计算
- 完善自动结算利息的四舍五入处理,避免精度丢失
- 修正支付端方向符号应用,确保方向只应用一次
- 更新TdInterestPrincipal计算逻辑,处理不同利息模式下的本金赋值
- 修复TdCloseInterest计算,合并手动和自动结算利息金额
- 优化InterestIncomeSum计算,正确处理结算后剩余未实现利息
This commit is contained in:
张名锐
2026-07-30 23:29:07 +08:00
parent f535ed362c
commit 3670dde9bf
3 changed files with 284 additions and 18 deletions
@@ -92,10 +92,12 @@ namespace YLErp.Modules.SwapModule
// public 包装:验证自动互换时的“高精度应结 -> 两位实际结算 -> 待实现尾差”链路。
public eod_swap_position ExecuteSaveAutoEodInterestPosition(
eod_swap_position eodPayPosition, swap_position position, trade td,
DateTime valueDate, IntervalModel interval)
DateTime valueDate, IntervalModel interval, eod_swap lastEodSwap = null,
decimal posiLongNotional = DealInterestsScenarioTest.Principal,
decimal orginPv = DealInterestsScenarioTest.Principal)
{
SaveAutoEodInterestPosition(eodPayPosition, null, position, td, valueDate, interval,
null, DealInterestsScenarioTest.Principal, 0m, 1m, DealInterestsScenarioTest.Principal);
lastEodSwap, posiLongNotional, 0m, 1m, orginPv);
return PersistedPositions.LastOrDefault();
}
@@ -697,10 +699,10 @@ namespace YLErp.Modules.SwapModule
var firstCloseFlow = CreateSwapFlowEvent(firstCloseDate, 0.01m);
firstCloseFlow.EventType = (int)SwapFlowEventTypeEnum.;
firstCloseFlow.InterestPrincipal = 50m;
// 模拟 CalcUnwindInterest: 上日尾差 + 本次平仓后的高精度待实现
// 模拟 CalcUnwindInterest上日尾差加当日新增,尚未扣除本次 0.01 平仓结算
service.AutoInterests = new List<swap_flow_event>
{
CreateAutoSwapFlowEvent(firstCloseDate, 0.006383561644m)
CreateAutoSwapFlowEvent(firstCloseDate, 0.016383561644m)
};
service.AutoInterests[0].InterestPrincipal = 50m;
var firstCloseResult = service.ExecuteSaveAutoEodWithCloseInterestPosition(
@@ -733,6 +735,239 @@ namespace YLErp.Modules.SwapModule
"全平后累计已实现应包含自动互换和两次平仓");
}
[TestMethod]
public void DI_AUTO_SETTLEMENT_004_PartialCloseAccruesOnlyAfterPreviousEod()
{
const decimal originalNotional = 10012.35m;
const decimal remainingNotional = 5006.17m;
const decimal closeNotional = 5006.172835m;
const decimal rate = 0.0299m;
const decimal pendingInterest = 0.820379534246m;
const decimal settledInterest = 0.82m;
const decimal expectedPendingInterest = 0.820569301369m;
var service = new StubEodPositionService();
var td = CreateTrade();
td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "01",
SettlementRules = 0
});
var position = CreateInterestPosition();
position.InterestRateDefault = rate;
position.InterestSwapInterval = null;
var previousEodDate = StartDate.AddDays(2);
var closeDate = previousEodDate.AddDays(1);
var previousEod = CreatePreEod(previousEodDate, pendingInterest, settledInterest);
previousEod.TdInterestPrincipal = originalNotional;
var closeFlow = CreateSwapFlowEvent(closeDate, settledInterest);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.;
closeFlow.InterestPrincipal = 5006.18m;
closeFlow.InterestRate = rate;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
previousEod, position, td, closeDate, null,
remainingNotional, 0m, new List<swap_flow_event> { closeFlow },
closeNotional, false);
AssertDecimal(expectedPendingInterest, result.InterestIncomeSum,
"Partial close must accrue only the day after the previous EOD snapshot");
AssertDecimal(remainingNotional, result.TdInterestPrincipal,
"The close-day snapshot must carry the remaining principal into the next EOD");
AssertDecimal(1.64m, result.RealizedInterest,
"Realized interest must include the previous and current settlements");
Assert.AreEqual(previousEodDate, service.LastInterestCalculationEodPosition.ValueDate,
"The previous EOD ValueDate must be preserved for accrual boundaries");
Assert.AreEqual(previousEod.id, service.LastInterestCalculationEodPosition.id,
"The previous EOD identity must not be reset to a new position");
}
[TestMethod]
public void DI_AUTO_SETTLEMENT_005_AutoSettlementKeepsRemainingPrincipal()
{
const decimal originalNotional = 10012.35m;
const decimal remainingNotional = 5006.17m;
const decimal rate = 0.0299m;
var settlementDate = new DateTime(2026, 7, 14);
var service = new StubEodPositionService();
var td = CreateTrade();
td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "01",
SettlementRules = 0
});
var position = CreateInterestPosition();
position.InterestRateDefault = rate;
position.InterestSwapInterval = null;
var previousEod = CreatePreEod(settlementDate.AddDays(-1), 2.460947197259m, 1.64m);
previousEod.TdInterestPrincipal = remainingNotional;
var staleAggregate = new eod_swap { NotionalValue = originalNotional };
var result = service.ExecuteSaveAutoEodInterestPosition(
previousEod, position, td, settlementDate,
new IntervalModel { Date = settlementDate, Rate = rate, Settlement = 1 },
staleAggregate, remainingNotional, originalNotional);
AssertDecimal(2.87m, result.TdCloseInterest,
"Automatic settlement must round the half-position interest to 2.87");
AssertDecimal(remainingNotional, result.TdInterestPrincipal,
"Automatic settlement must not restore the original principal from eod_swap");
}
[TestMethod]
public void DI_AUTO_SETTLEMENT_006_CloseAndAutoSettlementOnlySettlesRemainder()
{
var settleDate = new DateTime(2026, 7, 16);
var autoFlow = CreateAutoSwapFlowEvent(settleDate, 1.2345m);
var service = new StubEodPositionService
{
AutoInterests = new List<swap_flow_event> { autoFlow }
};
var closeFlow = CreateSwapFlowEvent(settleDate, 0.50m);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
CreatePreEod(settleDate.AddDays(-1), 0m), CreateInterestPosition(), CreateTrade(),
settleDate, new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 },
50m, 0m, new List<swap_flow_event> { closeFlow }, 50m, true);
AssertDecimal(0.73m, autoFlow.InterestAmount,
"Automatic settlement must deduct the 0.50 already settled by the close");
AssertDecimal(0.73m, autoFlow.InterestClosePnL,
"The automatic flow PnL must use the actual 2-decimal remainder");
AssertDecimal(1.23m, result.TdCloseInterest,
"EOD realized interest must include both manual and automatic settlements");
AssertDecimal(0.0045m, result.InterestIncomeSum,
"The high-precision total less actual settlements must remain unrealized");
AssertDecimal(1.23m, result.RealizedInterest,
"Cumulative realized interest must add the combined actual settlement once");
}
[TestMethod]
public void DI_AUTO_SETTLEMENT_007_PayLegKeepsUnsignedSettlementAndAppliesDirectionOnce()
{
var settleDate = new DateTime(2026, 7, 16);
var autoFlow = CreateAutoSwapFlowEvent(settleDate, 1.2345m);
autoFlow.InterestDirection = (int)SwapDirectionEnum.;
autoFlow.InterestClosePnL = -1.2345m;
var service = new StubEodPositionService
{
AutoInterests = new List<swap_flow_event> { autoFlow }
};
var position = CreateInterestPosition();
position.InterestDirection = (int)SwapDirectionEnum.;
var closeFlow = CreateSwapFlowEvent(settleDate, 0.50m);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.;
closeFlow.InterestDirection = (int)SwapDirectionEnum.;
closeFlow.InterestClosePnL = -0.50m;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
CreatePreEod(settleDate.AddDays(-1), 0m), position, CreateTrade(), settleDate,
new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 },
50m, 0m, new List<swap_flow_event> { closeFlow }, 50m, true);
AssertDecimal(0.73m, autoFlow.InterestAmount);
AssertDecimal(-0.73m, autoFlow.InterestClosePnL);
AssertDecimal(1.23m, result.TdCloseInterest,
"TdCloseInterest follows the unsigned settlement convention used by other interest branches");
AssertDecimal(0.0045m, result.InterestIncomeSum);
AssertDecimal(-1.23m, result.RealizedInterest,
"The pay direction must be applied exactly once when cumulative realized interest is stored");
}
[TestMethod]
public void DI_AUTO_SETTLEMENT_008_MarginLegAppliesReversedDirectionOnce()
{
var settleDate = new DateTime(2026, 7, 16);
var autoFlow = CreateAutoSwapFlowEvent(settleDate, 1.2345m);
autoFlow.InterestMode = (int)InterestModeEnum.;
var service = new StubEodPositionService
{
AutoInterests = new List<swap_flow_event> { autoFlow }
};
var position = CreateInterestPosition();
position.InterestMode = (int)InterestModeEnum.;
position.InterestPrincipalFix = 50m;
var closeFlow = CreateSwapFlowEvent(settleDate, 0.50m);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.;
closeFlow.InterestMode = (int)InterestModeEnum.;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
CreatePreEod(settleDate.AddDays(-1), 0m), position, CreateTrade(), settleDate,
new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 },
50m, 0m, new List<swap_flow_event> { closeFlow }, 50m, true);
AssertDecimal(0.73m, autoFlow.InterestAmount);
AssertDecimal(0.73m, autoFlow.InterestClosePnL);
AssertDecimal(1.23m, result.TdCloseInterest);
AssertDecimal(0.0045m, result.InterestIncomeSum);
AssertDecimal(-1.23m, result.RealizedInterest,
"A received margin principal produces payable interest, so the margin ratio reverses once");
}
[TestMethod]
public void DI_MANUAL_CLOSE_006_FinalCloseIncludesCloseDateInterest()
{
const decimal originalNotional = 10012.35m;
const decimal remainingNotional = 5006.17m;
const decimal rate = 0.0299m;
const decimal pendingInterest = 0.411136145205m;
const decimal expectedInterest = 0.821230619178m;
var closeDate = new DateTime(2026, 7, 16);
var service = new StubEodPositionService();
var td = CreateTrade();
td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "01",
SettlementRules = 0
});
var position = CreateInterestPosition();
position.InterestRateDefault = rate;
position.InterestSwapInterval = null;
var previousEod = CreatePreEod(closeDate.AddDays(-1), pendingInterest, 4.51m);
previousEod.TdInterestPrincipal = remainingNotional;
var previousFloatingPosition = new eod_swap_position
{
PosiDirection = (int)SwapDirectionEnum.,
PosiNotionalValue = remainingNotional
};
var previousAggregate = new eod_swap
{
NotionalValue = originalNotional,
NotionalValueLong = remainingNotional
};
var orginPv = SwapDealService.ResolveUnwindPreviousNotional(
previousAggregate, new List<eod_swap_position> { previousEod, previousFloatingPosition },
remainingNotional);
AssertDecimal(remainingNotional, SwapDealService.ResolveUnwindPreviousNotional(
previousAggregate, Array.Empty<eod_swap_position>(), originalNotional),
"Missing details must fall back to the aggregate directional notionals");
AssertDecimal(remainingNotional, SwapDealService.ResolveUnwindPreviousNotional(
null, null, remainingNotional),
"Missing EOD data must fall back to the current notional");
AssertDecimal(remainingNotional, SwapDealService.ResolveUnwindPreviousNotional(
new eod_swap { NotionalValue = originalNotional }, Array.Empty<eod_swap_position>(),
remainingNotional),
"Zero directional notionals must not override a non-zero current remaining notional");
var result = new SwapDealService(service).GetInterests(
td, td.trade_extend, closeDate, closeDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, orginPv,
false, settment: false, newCalcLast: false, closeList: null).Single();
AssertDecimal(remainingNotional, result.InterestPrincipal,
"Final close must accrue on the remaining principal");
AssertDecimal(expectedInterest, result.InterestAmount,
"InterestCalcMode 01 must include the final close date");
AssertDecimal(0.82m, Math.Round(result.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero), "Final close cash interest must be 0.82");
}
/// <summary>
/// [DI_MATURITY_SETTLEMENT_001] 到期日存在手动互换但未带齐待实现时不能清零;
/// 当前事件按两位覆盖全部可结金额后,才可视为最终结算并清零。
+24 -1
View File
@@ -565,7 +565,7 @@ namespace YLErp.Modules.SwapModule
var posiShortNotionalValue = shortPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金
var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue);
var posiNotionalValue = stockEqvNotional * closePercent;//剩余名义本金
var orginPv = lastEod != null ? lastEod.NotionalValue : stockEqvNotional;
var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional);
var grossPrice = realPostitions.Where(x => x.PosiDirection > 0).FirstOrDefault()?.PosiGrossPrice;
var closeList = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId && x.UnwindDate == unwindDate && eventTypes.Contains(x.EventType) && x.DataState == (int)SwapFlowDateStateEnum.).ToList();
bool tdClose = closeList.Count > 0;
@@ -607,6 +607,29 @@ namespace YLErp.Modules.SwapModule
}).ToList();
}
public static decimal ResolveUnwindPreviousNotional(
eod_swap lastEod,
IEnumerable<eod_swap_position> lastEodPositions,
decimal currentNotional)
{
var floatingPositions = lastEodPositions?.Where(x => x.PosiDirection > 0).ToList();
decimal previousNotional;
if (floatingPositions?.Count > 0)
{
previousNotional = floatingPositions.Sum(x => x.PosiNotionalValue);
}
else
{
previousNotional = lastEod == null
? currentNotional
: Math.Abs(lastEod.NotionalValueLong) + Math.Abs(lastEod.NotionalValueShort);
}
return previousNotional == 0m && currentNotional != 0m
? currentNotional
: previousNotional;
}
/// <summary>
/// 获取利息腿"已通过历史互换结出的累计利息"(用于复利重算时扣除,类比分红的 CalcConsumedDividend)。
/// 数据源为事件级 swap_flow_event.InterestAmount(互换/自动互换 完成态事件,互换当时即落库,不依赖日终归档)。
@@ -1161,13 +1161,7 @@ namespace YLErp.Modules.SwapModule
}
var tradeExtend = td.trade_extend.ExtendObj;
decimal oriPosiNotionalValue = posiLongNotional + posiShortNational;
decimal posiNotionalValue = oriPosiNotionalValue;
bool longShort = td.StructureType == ClientMarginTypeEnum..ToString();
if (lastEodSwap != null)
{
posiNotionalValue = lastEodSwap.NotionalValue;
}
decimal posiNotionalValue = posiLongNotional + posiShortNational;
decimal closePercent = 1;
decimal ratio = position.InterestDirection == (int)SwapDirectionEnum. ? 1m : -1m;//收取为正,支付为负
if (marginTypes.Contains(position.InterestMode))
@@ -1293,10 +1287,11 @@ namespace YLErp.Modules.SwapModule
var lastInterestFeeSum = eodPayPosition?.InterestFeeSum ?? 0m;
var lastRealizedInterest = eodPayPosition?.RealizedInterest ?? 0m;
var lastRealizedInterestFee = eodPayPosition?.RealizedInterestFee ?? 0m;
eodPayPosition = new eod_swap_position();
// 保留上一日日终标识和计息上下文,部分平仓只从 ValueDate 之后续算,不能重置到交易起始日。
eodPayPosition = eodPayPosition?.Clone() ?? new eod_swap_position();
eodPayPosition.ClientId = td.ClientId;
eodPayPosition.SwapTradeId = td.id;
// CalcSwapInterests 按 PositionId 查找上一日日终;id 仍保持 0,沿用盘中平仓的原有计息日期语义
// CalcSwapInterests 按 PositionId 匹配上一日日终。
eodPayPosition.PositionId = position.id;
eodPayPosition.PosiStartDate = td.StartDate.Value;
eodPayPosition.PosiMatuirityDate = td.ExerciseDate.Value;
@@ -1338,7 +1333,17 @@ namespace YLErp.Modules.SwapModule
preEodPositions.Add(eodPayPosition);
var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, closeNational, 1, eventType, false, true, grossPrice, orginPv, true, settment: false, newCalcLast: true);
decimal TdInterestAmount = interests.Sum(x => x.TdInterestAmount);
decimal InterestAmount = interests.Sum(x => x.InterestAmount);
decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount);
decimal manualSettledInterestAmount = flowEvents.Sum(x => x.InterestAmount);
decimal autoSettledInterestAmount = 0m;
if (autoSwap && interests.Count > 0)
{
autoSettledInterestAmount = RoundMoney(interestAmountBeforeSettlement - manualSettledInterestAmount);
var autoInterest = interests[0];
autoInterest.InterestAmount = autoSettledInterestAmount;
autoInterest.InterestClosePnL = autoSettledInterestAmount
* (autoInterest.InterestDirection == (int)SwapDirectionEnum. ? 1m : -1m);
}
newEodPayPosition.ValueDate = valueDate;
newEodPayPosition.PositionId = position.id;
UpdateDbOption(newEodPayPosition);
@@ -1359,7 +1364,9 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition.interest_rest_days = position.interest_rest_days;
newEodPayPosition.interest_rule = position.interest_rule;
//利息端估值用信息
newEodPayPosition.TdInterestPrincipal = interests.Count > 0 ? interests.First().InterestPrincipal : 0;
newEodPayPosition.TdInterestPrincipal = position.InterestMode == (int)InterestModeEnum.
? posiNotionalValue
: interests.Count > 0 ? interests.First().InterestPrincipal : 0;
if (interval != null)
{
newEodPayPosition.TdInterestRate = interval.Rate;
@@ -1371,7 +1378,7 @@ namespace YLErp.Modules.SwapModule
//当日已实现,平仓时已处理
newEodPayPosition.TdInterestFee = flowEvents.Sum(s => s.InterestFee);
newEodPayPosition.TdCloseInterestFee = newEodPayPosition.TdInterestFee;
newEodPayPosition.TdCloseInterest = flowEvents.Sum(s => s.InterestClosePnL);
newEodPayPosition.TdCloseInterest = manualSettledInterestAmount + autoSettledInterestAmount;
var intersetAcmount = newEodPayPosition.TdInterestPrincipal * (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate);
if (position.IsAnnualized)
{
@@ -1390,7 +1397,8 @@ namespace YLErp.Modules.SwapModule
}
else
{
newEodPayPosition.InterestIncomeSum = InterestAmount;
newEodPayPosition.InterestIncomeSum = RoundEodInterest(
interestAmountBeforeSettlement - newEodPayPosition.TdCloseInterest);
newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee;
}
//持仓内容-利息腿-损益统计(本方视角)