fix(swap): 修复利息互换平仓结算中的尾差处理和本金计算问题
- 修复ExecuteSaveAutoEodInterestPosition方法参数传递,添加lastEodSwap、posiLongNotional和orginPv参数 - 修正平仓利息计算逻辑,区分手动结算和平仓后自动结算的利息处理 - 新增ResolveUnwindPreviousNotional静态方法,优化平仓前本金计算逻辑 - 修复部分平仓后利息累积边界问题,确保仅从上次EOD快照后开始计算 - 完善自动结算利息的四舍五入处理,避免精度丢失 - 修正支付端方向符号应用,确保方向只应用一次 - 更新TdInterestPrincipal计算逻辑,处理不同利息模式下的本金赋值 - 修复TdCloseInterest计算,合并手动和自动结算利息金额 - 优化InterestIncomeSum计算,正确处理结算后剩余未实现利息
This commit is contained in:
@@ -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] 到期日存在手动互换但未带齐待实现时不能清零;
|
||||
/// 当前事件按两位覆盖全部可结金额后,才可视为最终结算并清零。
|
||||
|
||||
Reference in New Issue
Block a user