feat(swap): 添加收益互换利息计算功能及Excel场景测试
- 新增ExecuteSaveEodInterestPositionCopy方法用于保存日终利息持仓副本 - 添加ExcelScenario4Case类和相关测试数据用于验证利息计算准确性 - 实现DI_EXCEL_SCENARIO4_PartialCloseAndFinalCloseMatchBlAndBn测试方法 - 优化SwapDealService中的利息计算逻辑和平仓处理 - 添加详细的注释说明利息计算的核心业务规则 - 完善SwapEodPositionService中的日终持仓处理逻辑 - 修复部分平仓和最终平仓时的利息金额计算问题
This commit is contained in:
@@ -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。
|
||||
// 例如 0004:InterestPrincipal=90915227.13,InterestAmount=-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.14,InterestIncomeSum=-86611.313284,
|
||||
// RealizedInterest=-37119.14,TdInterestPrincipal=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_到期手动互换仅在结清后清零()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user