fix(swap): 修复复利计算和部分平仓处理中的多个问题(算头算尾合约复利平仓)

- 修复合约名义本金规模模式下部分平仓时名义本金计算错误
- 修复最终全平时复利利息计算中历史平仓尾差处理问题
- 修复复利重置日处理中利息重复注入历史本金的错误
- 修复部分平仓后日终待实现利息计算中本金比例应用问题
- 修复全平且实际金额覆盖应结利息后待实现利息清零逻辑
- 新增复合复利交换服务用于单元测试验证
- 添加多个测试用例覆盖复利计算边界场景
This commit is contained in:
张名锐
2026-08-08 15:51:29 +08:00
parent c379e31b4d
commit 6fdc7d80b2
3 changed files with 538 additions and 21 deletions
@@ -48,6 +48,8 @@ namespace YLErp.Modules.SwapModule
/// </summary>
public List<swap_flow_event> AutoInterests { get; set; }
public SwapDealService DealService { get; set; }
public eod_swap_position LastInterestCalculationEodPosition { get; private set; }
public StubEodPositionService() : base(nameof(DealInterestsScenarioTest))
@@ -74,7 +76,7 @@ namespace YLErp.Modules.SwapModule
return AutoInterests;
}
return new SwapDealService(this).GetInterests(td, tradeExtend, valueDate, unwindDate,
return (DealService ?? new SwapDealService(this)).GetInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
grossPrice, orginPv, add, settment, newCalcLast, closeList);
@@ -126,6 +128,27 @@ namespace YLErp.Modules.SwapModule
}
}
private sealed class StubCompoundSwapDealService : SwapDealService
{
private readonly IReadOnlyDictionary<DateTime, double> _floatRates;
public StubCompoundSwapDealService(IReadOnlyDictionary<DateTime, double> floatRates = null)
: base(new OptUserInfo(0, nameof(StubCompoundSwapDealService), OptUserFrom.UnitTest))
{
_floatRates = floatRates ?? new Dictionary<DateTime, double>();
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
return _floatRates.TryGetValue(valueDate.Date, out rate);
}
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
{
return 0m;
}
}
#endregion
#region
@@ -1085,6 +1108,437 @@ namespace YLErp.Modules.SwapModule
MidpointRounding.AwayFromZero), "Final close cash interest must be 0.82");
}
[TestMethod]
public void DI_MANUAL_CLOSE_007_CompoundFinalCloseSettlesFirstPartialCloseRoundingTail()
{
const decimal originalNotional = 10012.35m;
const decimal remainingNotional = originalNotional / 2m;
const decimal rate = 0.0299m;
var firstCloseDate = StartDate.AddDays(6);
var finalCloseDate = firstCloseDate.AddDays(6);
var td = CreateTrade();
td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11",
SettlementRules = 0
});
var position = CreateInterestPosition();
position.InterestType = (int)InterestTypeEnum.;
position.InterestRateDefault = rate;
position.InterestPrincipalFix = originalNotional;
position.interest_rest_days = 1;
position.InterestSwapInterval = null;
var dealService = new StubCompoundSwapDealService();
var eodService = new StubEodPositionService { DealService = dealService };
var firstCloseInterest = dealService.GetInterests(
td, td.trade_extend, firstCloseDate, firstCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
originalNotional, originalNotional, 0m, remainingNotional, 0.5m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
settment: false).Single();
var firstCloseCash = Math.Round(firstCloseInterest.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
var firstCloseRoundingTail = firstCloseInterest.InterestAmount - firstCloseCash;
Assert.AreNotEqual(0m, firstCloseRoundingTail,
$"首次部分平仓高精度利息 {firstCloseInterest.InterestAmount:F12} 按两位实际结算 {firstCloseCash:F2},尾差 {firstCloseRoundingTail:F12}");
var firstCloseFlow = CreateSwapFlowEvent(firstCloseDate, firstCloseCash);
firstCloseFlow.EventType = (int)SwapFlowEventTypeEnum.;
firstCloseFlow.InterestPrincipal = remainingNotional;
var firstCloseEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
null, position, td, firstCloseDate, null, remainingNotional, 0m,
new List<swap_flow_event> { firstCloseFlow }, remainingNotional, false);
var replayAtPreviousEod = dealService.GetInterests(
td, td.trade_extend, firstCloseDate, firstCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
settment: false).Single();
var replayAtFinalClose = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
settment: false).Single();
var expectedFinalInterest = firstCloseEod.InterestIncomeSum
+ replayAtFinalClose.InterestAmount - replayAtPreviousEod.InterestAmount;
var expectedFinalCash = Math.Round(expectedFinalInterest, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
var expectedTotalCash = Math.Round(firstCloseCash + firstCloseEod.InterestIncomeSum
+ replayAtFinalClose.InterestAmount - replayAtPreviousEod.InterestAmount,
ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
var finalCloseInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { firstCloseEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
settment: false).Single();
var finalCloseCash = Math.Round(finalCloseInterest.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
var finalCloseFlow = CreateSwapFlowEvent(finalCloseDate, finalCloseCash);
finalCloseFlow.EventType = (int)SwapFlowEventTypeEnum.;
finalCloseFlow.InterestPrincipal = remainingNotional;
var finalCloseEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
firstCloseEod, position, td, finalCloseDate, null, 0m, 0m,
new List<swap_flow_event> { finalCloseFlow }, remainingNotional, false);
AssertDecimal(expectedFinalCash, finalCloseCash,
"最终全平现金必须带走上一日日终的待实现利息尾差");
AssertDecimal(expectedTotalCash,
firstCloseCash + finalCloseCash,
"两次实际结算现金必须守恒");
AssertDecimal(0m, finalCloseEod.InterestIncomeSum, "全平且实际金额覆盖应结利息后待实现应清零");
AssertDecimal(firstCloseCash + finalCloseCash, finalCloseEod.RealizedInterest,
"累计已实现利息必须等于历次实际结算金额之和");
var incompleteFinalCloseFlow = CreateSwapFlowEvent(finalCloseDate, finalCloseCash - 0.01m);
incompleteFinalCloseFlow.EventType = (int)SwapFlowEventTypeEnum.;
incompleteFinalCloseFlow.InterestPrincipal = remainingNotional;
var incompleteFinalCloseEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
firstCloseEod, position, td, finalCloseDate, null, 0m, 0m,
new List<swap_flow_event> { incompleteFinalCloseFlow }, remainingNotional, false);
AssertDecimal(expectedFinalInterest - incompleteFinalCloseFlow.InterestAmount,
incompleteFinalCloseEod.InterestIncomeSum,
"最终全平流水少结 0.01 时,日终必须保留未结利息而非清零");
}
[TestMethod]
public void DI_GLMS_20260421_0007_ContractNotionalCompoundPartialCloseScalesAndFinalCloseUsesRemainingPrincipal()
{
const decimal notional = 303139117.8m;
const decimal partialPercent = 0.3m;
const decimal partialNotional = notional * partialPercent;
const decimal remainingNotional = notional - partialNotional;
const decimal spread = 0.0025m;
var startDate = new DateTime(2026, 4, 21);
var maturityDate = new DateTime(2026, 5, 19);
var partialCloseDate = new DateTime(2026, 5, 11);
var td = new trade
{
id = 7007,
TradeNumber = "GLMS-20260421-0007",
ClientId = 999998,
TradeType = "收益互换",
TradeDate = startDate,
StartDate = startDate,
ExerciseDate = maturityDate,
TradeStatus = "确认成交",
ValidState = "Valid",
trade_extend = new trade_extend
{
TradeId = 7007,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11",
SettlementRules = 0
})
}
};
var position = new swap_position
{
id = 70071,
SwapTradeId = td.id,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = spread,
InterestPrincipalFix = notional,
PosiStartDate = startDate,
PosiMatuirityDate = maturityDate,
IsInitial = true,
Invalid = false,
InterestType = (int)InterestTypeEnum.,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = maturityDate, Rate = spread, Settlement = 0 }
})
};
var service = new StubCompoundSwapDealService(new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 20)] = 0.0132,
[new DateTime(2026, 4, 21)] = 0.0132,
[new DateTime(2026, 4, 22)] = 0.0132,
[new DateTime(2026, 4, 23)] = 0.0132,
[new DateTime(2026, 4, 24)] = 0.0131,
[new DateTime(2026, 4, 27)] = 0.013502,
[new DateTime(2026, 4, 28)] = 0.0136,
[new DateTime(2026, 4, 29)] = 0.0138,
[new DateTime(2026, 4, 30)] = 0.0139,
[new DateTime(2026, 5, 4)] = 0.0139,
[new DateTime(2026, 5, 5)] = 0.0139,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 7)] = 0.0136,
[new DateTime(2026, 5, 8)] = 0.0135,
[new DateTime(2026, 5, 9)] = 0.0131,
[new DateTime(2026, 5, 11)] = 0.0134,
[new DateTime(2026, 5, 12)] = 0.013,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 14)] = 0.013,
[new DateTime(2026, 5, 15)] = 0.013,
[new DateTime(2026, 5, 18)] = 0.0132,
[new DateTime(2026, 5, 19)] = 0.0131
});
var previousEod = new eod_swap_position
{
id = 70072,
SwapTradeId = td.id,
PositionId = position.id,
ValueDate = new DateTime(2026, 5, 10),
TdInterestPrincipal = 303324019.3183441374m,
InterestIncomeSum = 266674.349853521170m,
InterestProfitSum = 266674.349853521170m,
FloatRate = 0.0139m
};
var partial = service.GetInterests(
td, td.trade_extend, partialCloseDate, partialCloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
notional, notional, 0m, partialNotional, partialPercent,
(int)SwapEventTypeEnum., false, false, 0m, notional,
settment: false).Single();
AssertDecimal(84090.95m, Math.Round(partial.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero),
"GLMS-20260421-0007 30% 复利合约名义本金平仓必须按比例结算");
var final = service.GetInterests(
td, td.trade_extend, maturityDate, maturityDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, remainingNotional,
settment: false, newCalcLast: true).Single();
AssertDecimal(268428.73m, Math.Round(final.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero),
"后续全平必须只结算剩余70%本金的复利,不重复结算原始全额");
Assert.AreNotEqual(280303.16m,
Math.Round(final.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero),
"后续全平不得再次使用原始全额本金");
}
[TestMethod]
public void DI_GLMS_20260421_0007_EodPartialCloseCarriesRemainingCompoundAccrual()
{
const decimal originalNotional = 303139117.8m;
const decimal partialNotional = 90941735.34m;
const decimal remainingNotional = 212197382.46m;
const decimal spread = 0.0025m;
var partialCloseDate = new DateTime(2026, 5, 11);
var intermediateDate = new DateTime(2026, 5, 18);
var finalCloseDate = new DateTime(2026, 5, 19);
var td = new trade
{
id = 7007,
TradeNumber = "GLMS-20260421-0007-EOD",
ClientId = 999998,
TradeType = "收益互换",
TradeDate = new DateTime(2026, 4, 21),
StartDate = new DateTime(2026, 4, 21),
ExerciseDate = finalCloseDate,
TradeStatus = "确认成交",
ValidState = "Valid",
StructureType = "单标的",
QuoteCurrency = "CNY",
SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = 7007,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11",
SettlementRules = 0
})
}
};
var position = new swap_position
{
id = 70071,
SwapTradeId = td.id,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = spread,
InterestPrincipalFix = originalNotional,
PosiStartDate = td.StartDate.Value,
PosiMatuirityDate = finalCloseDate,
IsInitial = true,
Invalid = false,
InterestType = (int)InterestTypeEnum.,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = finalCloseDate, Rate = spread, Settlement = 0 }
})
};
var dealService = new StubCompoundSwapDealService(new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 21)] = 0.0132,
[new DateTime(2026, 4, 28)] = 0.0136,
[new DateTime(2026, 4, 30)] = 0.0139,
[new DateTime(2026, 5, 4)] = 0.0139,
[new DateTime(2026, 5, 5)] = 0.0139,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 11)] = 0.0134,
[new DateTime(2026, 5, 12)] = 0.013,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 19)] = 0.0131
});
var eodService = new StubEodPositionService { DealService = dealService };
var previousEod = new eod_swap_position
{
id = 70072,
SwapTradeId = td.id,
PositionId = position.id,
ValueDate = new DateTime(2026, 5, 10),
InterestDirection = position.InterestDirection,
InterestMode = position.InterestMode,
InterestType = position.InterestType,
InterestRateDefault = spread,
InterestIncomeSum = 266674.349853521170m,
InterestProfitSum = 266674.349853521170m,
TdInterestPrincipal = 303324019.3183441374m,
PosiNotionalValue = originalNotional,
FloatRate = 0.0139m,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0
};
var partialCloseFlow = new swap_flow_event
{
SwapTradeId = td.id,
PositionId = position.id,
EventType = (int)SwapFlowEventTypeEnum.,
EventDate = partialCloseDate,
UnwindDate = partialCloseDate,
InterestDirection = position.InterestDirection,
InterestRate = spread,
InterestPrincipal = partialNotional,
InterestAmount = 84090.95m,
InterestClosePnL = 84090.95m,
DataState = (int)SwapFlowDateStateEnum.
};
var partialEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
previousEod, position, td, partialCloseDate, null, remainingNotional, 0m,
new List<swap_flow_event> { partialCloseFlow }, partialNotional, false);
AssertDecimal(13628.805251563956m, partialEod.TdInterestIncome,
"5/11 EOD 当日新增复利必须按平仓前全额本金计提");
AssertDecimal(196212.205105085126m, partialEod.InterestIncomeSum,
"5/11 EOD 应保留部分平仓后的待实现复利");
var intermediateInterest = dealService.GetInterests(
td, td.trade_extend, intermediateDate, intermediateDate,
new List<eod_swap_position> { partialEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, originalNotional,
settment: false, newCalcLast: true).Single();
Assert.IsTrue(Math.Abs(259348.386714765m - intermediateInterest.InterestAmount) <= 0.01m,
$"5/18 复利平仓应承接 5/11 日终剩余本金的累计利息 Expected approximately 259348.386714765, Actual: {intermediateInterest.InterestAmount}");
}
[TestMethod]
public void DI_GLMS_20260421_0006_FinalCloseCarriesPreviousEodInterest()
{
const decimal remainingNotional = 212197382.46m;
const decimal expectedInterest = -119386.71m;
var finalCloseDate = new DateTime(2026, 5, 19);
var td = new trade
{
id = 6006,
TradeNumber = "GLMS-20260421-0006",
ClientId = 999998,
TradeType = "收益互换",
TradeDate = new DateTime(2026, 4, 21),
StartDate = new DateTime(2026, 4, 22),
ExerciseDate = finalCloseDate,
TradeStatus = "确认成交",
ValidState = "Valid",
StructureType = "单标的",
QuoteCurrency = "CNY",
SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = 6006,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10",
SettlementRules = 0
})
}
};
var position = new swap_position
{
id = 60061,
SwapTradeId = td.id,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = -0.021m,
InterestPrincipalFix = remainingNotional,
PosiStartDate = td.StartDate.Value,
PosiMatuirityDate = td.ExerciseDate.Value,
IsInitial = true,
Invalid = false,
InterestType = (int)InterestTypeEnum.,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = null
};
var previousEod = new eod_swap_position
{
id = 60062,
SwapTradeId = td.id,
PositionId = position.id,
ValueDate = new DateTime(2026, 5, 18),
InterestDirection = position.InterestDirection,
InterestMode = position.InterestMode,
InterestType = position.InterestType,
InterestRateDefault = position.InterestRateDefault,
FloatRate = 0.0132m,
InterestIncomeSum = expectedInterest,
InterestProfitSum = expectedInterest,
TdInterestPrincipal = remainingNotional,
PosiNotionalValue = remainingNotional,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0
};
var dealService = new StubCompoundSwapDealService(new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 22)] = 0.0132,
[new DateTime(2026, 4, 29)] = 0.0138,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 19)] = 0.0131
});
var result = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 0m, remainingNotional,
settment: false).Single();
AssertDecimal(expectedInterest, result.InterestAmount,
"GLMS-20260421-0006 最终全平应承接 5/18 日终待实现利息");
Assert.AreNotEqual(-123072.67m, result.InterestAmount,
"不得回归旧库错误的 -123072.67 最终利息");
}
/// <summary>
/// [DI_MATURITY_SETTLEMENT_001] 到期日存在手动互换但未带齐待实现时不能清零;
/// 当前事件按两位覆盖全部可结金额后,才可视为最终结算并清零。