feat(swap): 添加收益互换利息计算功能及Excel场景测试

- 新增ExecuteSaveEodInterestPositionCopy方法用于保存日终利息持仓副本
- 添加ExcelScenario4Case类和相关测试数据用于验证利息计算准确性
- 实现DI_EXCEL_SCENARIO4_PartialCloseAndFinalCloseMatchBlAndBn测试方法
- 优化SwapDealService中的利息计算逻辑和平仓处理
- 添加详细的注释说明利息计算的核心业务规则
- 完善SwapEodPositionService中的日终持仓处理逻辑
- 修复部分平仓和最终平仓时的利息金额计算问题
This commit is contained in:
张名锐
2026-08-09 22:07:19 +08:00
parent 48e8447925
commit 2c4cd56111
3 changed files with 306 additions and 16 deletions
@@ -115,6 +115,16 @@ namespace YLErp.Modules.SwapModule
return PersistedPositions.LastOrDefault();
}
public eod_swap_position ExecuteSaveEodInterestPositionCopy(
eod_swap_position eodPayPosition, swap_position position, trade td,
DateTime valueDate, decimal posiLongNotional, decimal posiShortNotional,
decimal grossPrice, decimal orginPv)
{
SaveEodInterestPositionCopy(eodPayPosition, null, valueDate, td, position, null,
false, posiLongNotional, posiShortNotional, grossPrice, orginPv);
return PersistedPositions.LastOrDefault();
}
// public 包装:直接调用 protected virtual DealInterests(已改为 virtual,无需反射)
public void ExecuteDealInterests(
List<swap_position> interestList, List<eod_swap_position> eodPositions,
@@ -258,6 +268,86 @@ namespace YLErp.Modules.SwapModule
$"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}");
}
public sealed class ExcelScenario4Case
{
public string TradeNumber { get; init; }
public DateTime StartDate { get; init; }
public int SettlementRules { get; init; }
public int InterestMode { get; init; }
public int InterestType { get; init; }
public string InterestCalcMode { get; init; }
public int InterestRule { get; init; }
public decimal FixedRate { get; init; }
public decimal ExpectedPartialInterest { get; init; }
public decimal ExpectedFinalInterest { get; init; }
public override string ToString() => TradeNumber;
}
public static IEnumerable<object[]> ExcelScenario4Cases => new List<ExcelScenario4Case>
{
ExcelCase("GLMS-20260421-0008", new DateTime(2026, 4, 22), 1, 2, 1, "11", 0, -0.021m, -37218.76m, -124093.74m),
ExcelCase("GLMS-20260421-0007", new DateTime(2026, 4, 21), 0, 2, 1, "11", 0, 0.0025m, 84090.95m, 268428.73m),
ExcelCase("GLMS-20260421-0006", new DateTime(2026, 4, 22), 1, 9, 1, "10", 0, -0.021m, -35375.54m, -119386.71m),
ExcelCase("GLMS-20260421-0005", new DateTime(2026, 4, 21), 0, 2, 1, "10", 0, 0.0025m, 80002.31m, 259348.38m),
ExcelCase("GLMS-20260421-0004", new DateTime(2026, 4, 22), 1, 9, 1, "11", -1, -0.021m, -37119.14m, -123280.17m),
ExcelCase("GLMS-20260421-0003", new DateTime(2026, 4, 21), 0, 2, 1, "11", -1, 0.0025m, 83919.92m, 269717.13m),
ExcelCase("GLMS-20260421-0002", new DateTime(2026, 4, 22), 1, 9, 1, "10", -1, -0.021m, -35350.65m, -118631.26m),
ExcelCase("GLMS-20260421-0001", new DateTime(2026, 4, 21), 0, 9, 1, "10", -1, 0.0025m, 79831.29m, 260578.53m),
ExcelCase("GLMS-20260421-0012", new DateTime(2026, 4, 22), 1, 9, 0, "11", -1, -0.021m, -37124.16m, -123307.03m),
ExcelCase("GLMS-20260421-0011", new DateTime(2026, 4, 21), 0, 2, 0, "11", -1, 0.0025m, 83894.12m, 269586.02m),
ExcelCase("GLMS-20260421-0010", new DateTime(2026, 4, 22), 1, 9, 0, "10", 0, -0.021m, -35380.07m, -119411.90m),
ExcelCase("GLMS-20260421-0009", new DateTime(2026, 4, 21), 0, 9, 0, "10", -1, 0.0025m, 79807.97m, 260458.63m)
}.Select(x => new object[] { x });
private static ExcelScenario4Case ExcelCase(string tradeNumber, DateTime startDate,
int settlementRules, int interestMode, int interestType, string interestCalcMode,
int interestRule, decimal fixedRate, decimal expectedPartialInterest,
decimal expectedFinalInterest)
{
return new ExcelScenario4Case
{
TradeNumber = tradeNumber,
StartDate = startDate,
SettlementRules = settlementRules,
InterestMode = interestMode,
InterestType = interestType,
InterestCalcMode = interestCalcMode,
InterestRule = interestRule,
FixedRate = fixedRate,
ExpectedPartialInterest = expectedPartialInterest,
ExpectedFinalInterest = expectedFinalInterest
};
}
private static IReadOnlyDictionary<DateTime, double> CreateExcelScenario4Fr007Rates()
{
return 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, 11)] = 0.0134,
[new DateTime(2026, 5, 12)] = 0.0130,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 14)] = 0.0130,
[new DateTime(2026, 5, 15)] = 0.0130,
[new DateTime(2026, 5, 18)] = 0.0132,
[new DateTime(2026, 5, 19)] = 0.0131
};
}
#endregion
// ================================================================
@@ -1753,6 +1843,154 @@ namespace YLErp.Modules.SwapModule
/// [DI_MATURITY_SETTLEMENT_001] 到期日存在手动互换但未带齐待实现时不能清零;
/// 当前事件按两位覆盖全部可结金额后,才可视为最终结算并清零。
/// </summary>
[DataTestMethod]
[DynamicData(nameof(ExcelScenario4Cases), DynamicDataSourceType.Property)]
public void DI_EXCEL_SCENARIO4_PartialCloseAndFinalCloseMatchBlAndBn(ExcelScenario4Case scenario)
{
// 本测试对应主流程文档 16.13 节。四个规模字段按以下恒等式变化:
// 原始本金 303139117.80 = 本次平仓 90941735.34 + 收盘后剩余 212197382.46。
const decimal originalNotional = 303139117.80m;
const decimal partialClosePercent = 0.30m;
const decimal partialNotional = 90941735.34m;
const decimal remainingNotional = 212197382.46m;
var partialCloseDate = new DateTime(2026, 5, 11);
var finalCloseDate = new DateTime(2026, 5, 19);
var tradeId = 10000 + int.Parse(scenario.TradeNumber[^4..]);
var td = new trade
{
id = tradeId,
TradeNumber = scenario.TradeNumber,
ClientId = 999998,
TradeType = "收益互换",
TradeDate = new DateTime(2026, 4, 21),
StartDate = scenario.StartDate,
ExerciseDate = finalCloseDate,
TradeStatus = "确认成交",
ValidState = "Valid",
StructureType = "单标的",
QuoteCurrency = "CNY",
SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = tradeId,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = scenario.InterestCalcMode,
SettlementRules = scenario.SettlementRules
})
}
};
var position = new swap_position
{
id = tradeId * 10L + 1,
SwapTradeId = tradeId,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = scenario.InterestMode,
InterestRateDefault = scenario.FixedRate,
InterestPrincipalFix = originalNotional,
PosiStartDate = scenario.StartDate,
PosiMatuirityDate = finalCloseDate,
IsInitial = true,
Invalid = false,
InterestType = scenario.InterestType,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = scenario.InterestRule,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = finalCloseDate, Rate = scenario.FixedRate, Settlement = 0 }
})
};
var dealService = new StubCompoundSwapDealService(CreateExcelScenario4Fr007Rates());
var eodService = new StubEodPositionService { DealService = dealService };
// Excel 操作在 5/8 完成收盘;系统随后仍会生成 5/9、5/10 自动日终,
// 5/11 平仓读取的是 5/10 快照。漏掉周末快照会让平仓后待实现少两天全额利息。
var preCloseEodDates = Enumerable.Range(0,
(partialCloseDate.AddDays(-1) - scenario.StartDate).Days + 1)
.Select(day => scenario.StartDate.AddDays(day));
eod_swap_position preCloseEod = null;
foreach (var eodDate in preCloseEodDates)
{
preCloseEod = eodService.ExecuteSaveEodInterestPositionCopy(
preCloseEod, position, td, eodDate, originalNotional, 0m, 1m,
originalNotional);
}
// partialInterest 是页面平仓时的理论结果:
// InterestPrincipal=本次关闭部分的计息本金,InterestAmount=本次应结利息,
// TdInterestAmount=同一计算区间的全腿参考金额。BL 只核对实际要结的 InterestAmount。
// 例如 0004InterestPrincipal=90915227.13InterestAmount=-37119.14。
var partialInterest = dealService.GetInterests(
td, td.trade_extend, partialCloseDate, partialCloseDate,
new List<eod_swap_position> { preCloseEod }, new List<swap_position> { position },
originalNotional, originalNotional, 0m, partialNotional, partialClosePercent,
(int)SwapEventTypeEnum., false, false, 1m, originalNotional,
settment: false).Single();
AssertExcelMoney(scenario.ExpectedPartialInterest, partialInterest.InterestAmount,
$"{scenario.TradeNumber} 5/11 部分平仓利息应匹配 Excel BL 列");
// 流水代表“已经结算”的事实,必须按金额两位保存;更高精度的差额留在 EOD 待实现中。
var partialCashInterest = Math.Round(partialInterest.InterestAmount,
ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
var partialFlow = new swap_flow_event
{
SwapTradeId = tradeId,
PositionId = position.id,
EventType = (int)SwapFlowEventTypeEnum.,
EventDate = partialCloseDate,
UnwindDate = partialCloseDate,
InterestDirection = position.InterestDirection,
InterestMode = position.InterestMode,
InterestRate = partialInterest.InterestRate,
FloatRate = partialInterest.FloatRate,
InterestPrincipal = partialInterest.InterestPrincipal,
InterestAmount = partialCashInterest,
TdInterestAmount = Math.Round(partialInterest.TdInterestAmount,
ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero),
InterestClosePnL = partialCashInterest,
DataState = (int)SwapFlowDateStateEnum.
};
// partialEod 是 5/11 收盘后的状态,不是流水副本。以 0004 为例:
// TdCloseInterest=-37119.14InterestIncomeSum=-86611.313284
// RealizedInterest=-37119.14TdInterestPrincipal=212135529.974418。
var partialEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
preCloseEod, position, td, partialCloseDate, null, remainingNotional, 0m,
new List<swap_flow_event> { partialFlow }, partialNotional, false);
// 5/11 部分平仓收盘后继续逐自然日归档到 5/18,保留剩余 70% 仓位的完整利息。
var postCloseEodDates = Enumerable.Range(1, 7)
.Select(day => partialCloseDate.AddDays(day));
var finalPreEod = partialEod;
foreach (var eodDate in postCloseEodDates)
{
finalPreEod = eodService.ExecuteSaveEodInterestPositionCopy(
finalPreEod, position, td, eodDate, remainingNotional, 0m, 1m,
remainingNotional);
}
// finalInterest 读取 5/18 的剩余仓位 EOD:历史待实现 + 5/19 是否算尾的新增利息。
// 最终 closePercent=100%,因此 InterestAmount 必须带走此前部分平仓留下的全部尾差。
var finalInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { finalPreEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum., false, false, 1m, remainingNotional,
settment: false).Single();
AssertExcelMoney(scenario.ExpectedFinalInterest, finalInterest.InterestAmount,
$"{scenario.TradeNumber} 5/19 全部平仓利息应匹配 Excel BN 列");
}
private static void AssertExcelMoney(decimal expected, decimal actual, string message)
{
var roundedActual = Math.Round(actual, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
Assert.IsTrue(Math.Abs(expected - roundedActual) <= 0.01m,
$"{message}。Expected={expected}, Actual={actual}, Rounded={roundedActual}, Diff={expected - roundedActual}");
}
[TestMethod]
public void DI_MATURITY_SETTLEMENT_001_到期手动互换仅在结清后清零()
{