fix(swap): 修复复利重置日部分平仓后本金计算逻辑

- 修复复利重置日部分平仓后当日利息计算,使用已结转待实现利息的剩余复利本金
- 修复算尾部分平仓后EOD保留剩余复利本金的逻辑,按计息模式返回口径处理
- 修复模式2和模式9在部分平仓时的本金计算差异,避免重复比例调整
- 修复不算尾情况下重置日后已并入本金的待实现利息遗漏问题
- 新增对话案例回归测试验证部分平仓后最终全平场景的正确性
- 添加多种计息模式和交易类型的回归测试用例
This commit is contained in:
张名锐
2026-08-09 16:06:09 +08:00
parent 421662a07c
commit 48e8447925
3 changed files with 407 additions and 6 deletions
@@ -837,6 +837,8 @@ namespace YLErp.Modules.SwapModule
AssertDecimal(compoundPrincipalAfterSevenDays, result.TdInterestPrincipal,
"复利重置日部分平仓后,EOD 本金必须保留 CalcSwapInterests 计算的 7 天复利本金");
AssertDecimal(compoundPrincipalAfterSevenDays * FixedRate / AnnualDays, result.TdInterestIncome,
"复利重置日部分平仓后,当日利息必须使用已结转待实现利息的剩余复利本金");
}
[TestMethod]
@@ -901,6 +903,42 @@ namespace YLErp.Modules.SwapModule
"算尾部分平仓:InterestIncomeSum 应=前日待实现+当日新计(含被平仓部分)-当日实现");
}
[DataTestMethod]
[DataRow((int)InterestModeEnum.合约名义本金规模, "300")]
[DataRow((int)InterestModeEnum.标的期初全价, "700")]
public void DI_GLMS_20260421_0004_CalcLastKeepsRemainingCompoundPrincipal(
int interestMode, string calculatedPrincipalText)
{
var service = new StubEodPositionService
{
AutoInterests = new List<swap_flow_event>
{
new() { InterestPrincipal = decimal.Parse(calculatedPrincipalText) }
}
};
var td = CreateTrade();
td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11",
SettlementRules = 1
});
var position = CreateInterestPosition();
position.InterestMode = interestMode;
position.InterestType = (int)InterestTypeEnum.;
var previousEod = CreatePreEod(StartDate.AddDays(2), 100m);
previousEod.TdInterestPrincipal = Principal;
var closeFlow = CreateSwapFlowEvent(StartDate.AddDays(3), 50m);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
previousEod, position, td, StartDate.AddDays(3), null,
700m, 0m, new List<swap_flow_event> { closeFlow }, 300m, false);
AssertDecimal(700m, result.TdInterestPrincipal,
"算尾部分平仓后,EOD 必须按计息模式的返回口径保留剩余70%复利本金");
}
[TestMethod]
public void DI_MANUAL_PARTIAL_CLOSE_NoCalcLastUsesRemainingPrincipalDailyInterest()
{
@@ -0,0 +1,358 @@
using Newtonsoft.Json;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 对话及缺陷表中的部分平仓后最终全平案例。
/// 使用生产 GetInterests 计算,不连接数据库;数据库数值仅作为冻结输入快照。
/// </summary>
[TestClass]
public class SwapCloseConversationCasesRegressionTest
{
private const int AnnualDays = 365;
private const decimal CentTolerance = 0.015m;
public sealed class CloseCase
{
public string TradeNumber { get; init; }
public DateTime StartDate { get; init; }
public DateTime CloseDate { get; init; }
public string InterestCalcMode { get; init; }
public int SettlementRules { get; init; }
public int InterestMode { get; init; }
public int InterestType { get; init; }
public int ResetDays { get; init; }
public int InterestRule { get; init; }
public decimal FixedRate { get; init; }
public decimal PreviousPrincipal { get; init; }
public decimal PreviousPendingInterest { get; init; }
public decimal PreviousFloatRate { get; init; }
public decimal CloseFloatRate { get; init; }
public decimal OriginalNotional { get; init; }
public decimal RemainingNotional { get; init; }
public decimal InitialQuantity { get; init; }
public decimal PartialCloseQuantity { get; init; }
public decimal PartialCloseInterest { get; init; }
public decimal ExpectedFinalInterest { get; init; }
public override string ToString() => TradeNumber;
}
private sealed class SnapshotSwapDealService : SwapDealService
{
private readonly double _floatRate;
private readonly IReadOnlyDictionary<DateTime, double> _floatRates;
public SnapshotSwapDealService(decimal floatRate)
: base(new OptUserInfo(0, nameof(SwapCloseConversationCasesRegressionTest), OptUserFrom.UnitTest))
{
_floatRate = (double)floatRate;
_floatRates = BuildAprFloatRates();
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
if (_floatRates.TryGetValue(valueDate.Date, out rate))
{
return true;
}
rate = _floatRate;
return true;
}
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
=> 0m;
}
public static IEnumerable<object[]> ConversationCases => BuildCases().Select(x => new object[] { x });
[DataTestMethod]
[DynamicData(nameof(ConversationCases), DynamicDataSourceType.Property)]
public void FinalCloseMatchesConversationCase(CloseCase closeCase)
{
var trade = CreateTrade(closeCase);
var position = CreatePosition(closeCase);
var previousEod = CreatePreviousEod(closeCase, position);
var service = new SnapshotSwapDealService(closeCase.CloseFloatRate);
var result = service.GetInterests(
trade, trade.trade_extend, closeCase.CloseDate, closeCase.CloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
closeCase.RemainingNotional, closeCase.RemainingNotional, 0m,
closeCase.RemainingNotional, 1m, (int)SwapEventTypeEnum.,
false, false, 0m,
closeCase.InterestType == 0 ? closeCase.RemainingNotional : closeCase.OriginalNotional,
add: false, settment: false, newCalcLast: false).Single();
AssertAmount(closeCase.ExpectedFinalInterest, result.InterestAmount,
$"{closeCase.TradeNumber} 最终全平利息");
if (closeCase.InterestCalcMode.EndsWith("0"))
{
AssertAmount(closeCase.PreviousPendingInterest, result.InterestAmount,
$"{closeCase.TradeNumber} 不算尾时必须带走上日全部待实现利息");
}
else
{
Assert.AreNotEqual(
Math.Round(closeCase.PreviousPendingInterest, 2, MidpointRounding.AwayFromZero),
Math.Round(result.InterestAmount, 2, MidpointRounding.AwayFromZero),
$"{closeCase.TradeNumber} 算尾时必须包含最终平仓日新增利息");
}
}
[DataTestMethod]
[DynamicData(nameof(ConversationCases), DynamicDataSourceType.Property)]
public void PartialCloseSnapshotKeepsOriginalRatioAndRemainingTail(CloseCase closeCase)
{
var closePercentOfOriginal = closeCase.PartialCloseQuantity / closeCase.InitialQuantity;
var expectedPercent = closeCase.TradeNumber.Contains("JIATT") ? 0.4m : 0.3m;
Assert.AreEqual(expectedPercent, closePercentOfOriginal,
$"{closeCase.TradeNumber} 部分平仓比例必须按期初数量口径记录");
Assert.AreNotEqual(0m, closeCase.PartialCloseInterest,
$"{closeCase.TradeNumber} 5/11 或 8/4 部分平仓利息快照不得丢失");
Assert.AreNotEqual(0m, closeCase.PreviousPendingInterest,
$"{closeCase.TradeNumber} 最终平仓前待实现尾差不得提前清零");
}
private static trade CreateTrade(CloseCase closeCase)
{
return new trade
{
id = 1,
TradeNumber = closeCase.TradeNumber,
TradeDate = closeCase.StartDate,
StartDate = closeCase.StartDate,
ExerciseDate = closeCase.CloseDate,
TradeStatus = "确认成交",
ValidState = "Valid",
trade_extend = new trade_extend
{
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = closeCase.InterestCalcMode,
SettlementRules = closeCase.SettlementRules
})
}
};
}
private static swap_position CreatePosition(CloseCase closeCase)
{
return new swap_position
{
id = 1,
PositionType = 0,
InterestDirection = 1,
InterestMode = closeCase.InterestMode,
InterestType = closeCase.InterestType,
InterestRateDefault = closeCase.FixedRate,
InterestPrincipalFix = closeCase.OriginalNotional,
PosiStartDate = closeCase.StartDate,
PosiMatuirityDate = closeCase.CloseDate,
IsInitial = true,
Invalid = false,
IsAnnualized = true,
interest_rest_days = closeCase.ResetDays,
interest_rule = closeCase.InterestRule,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel
{
Date = closeCase.CloseDate,
Rate = closeCase.FixedRate,
Settlement = 0
}
})
};
}
private static eod_swap_position CreatePreviousEod(CloseCase closeCase, swap_position position)
{
return new eod_swap_position
{
id = 1,
PositionId = position.id,
ValueDate = closeCase.CloseDate.AddDays(-1),
InterestDirection = position.InterestDirection,
InterestMode = position.InterestMode,
InterestType = position.InterestType,
InterestRateDefault = closeCase.FixedRate,
InterestIncomeSum = closeCase.PreviousPendingInterest,
InterestProfitSum = closeCase.PreviousPendingInterest,
TdInterestPrincipal = closeCase.PreviousPrincipal,
PosiNotionalValue = 0m,
FloatRate = closeCase.PreviousFloatRate,
IsAnnualized = true,
interest_rest_days = closeCase.ResetDays,
interest_rule = closeCase.InterestRule
};
}
private static void AssertAmount(decimal expected, decimal actual, string message)
{
Assert.IsTrue(Math.Abs(expected - actual) <= CentTolerance,
$"{message}。Expected={expected}, Actual={actual}, Diff={expected - actual}");
}
private static IReadOnlyDictionary<DateTime, double> BuildAprFloatRates()
{
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, 9)] = 0.0131,
[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
};
}
private static IReadOnlyList<CloseCase> BuildCases()
{
var apr21Mode9NoLast = AprCase("", new DateTime(2026, 4, 21), "10", 0, 9, 1, -1,
0.0025m, 212393195.604981356504m, 260578.522161724795m, 0.0134m, 0.0132m,
79831.29m, 260578.53m);
var apr21Mode2NoLast = AprCase("", new DateTime(2026, 4, 21), "10", 0, 2, 1, 0,
0.0025m, 212393594.673529615939m, 259348.391672295294m, 0.0130m, 0.0131m,
80002.30m, 259348.38m);
var apr21Mode2WithLast = AprCase("", new DateTime(2026, 4, 21), "11", 0, 2, 1, 0,
0.0025m, 212393594.665105085126m, 259348.383245260196m, 0.0130m, 0.0131m,
84090.95m, 268428.73m);
var apr22Mode9NoLast = AprCase("", new DateTime(2026, 4, 22), "10", 1, 9, 1, -1,
-0.0210m, 212106644.672742434546m, -118631.263817268568m, 0.0130m, 0.0130m,
-35350.65m, -118631.26m);
var apr22Mode2WithLast = AprCase("", new DateTime(2026, 4, 22), "11", 1, 2, 1, 0,
-0.0210m, 212106237.833444880927m, -119386.717400887353m, 0.0129m, 0.0129m,
-37218.76m, -124093.74m);
var apr21SimpleWithLast = AprCase("", new DateTime(2026, 4, 21), "11", 0, 2, 0, -1,
0.0025m, 212197382.46m, 260458.629530704663m, 0.0134m, 0.0132m,
83894.12m, 269586.02m);
return new List<CloseCase>
{
WithTradeNumber(apr21Mode2WithLast, "GLMS-20260421-0007"),
WithTradeNumber(apr21Mode2NoLast, "GLMS-20260421-0005"),
WithTradeNumber(apr21Mode9NoLast, "GLMS-20260421-0001"),
WithTradeNumber(apr22Mode2WithLast, "GLMS-20260421-0008"),
WithTradeNumber(apr21SimpleWithLast, "GLMS-20260421-0011"),
WithTradeNumber(apr21Mode2WithLast, "GLMS-MARSK-20260421-FICC-01-180205IB"),
WithTradeNumber(apr21Mode9NoLast, "GLMS-MARSK-20260421-FICC-02-180205IB"),
WithTradeNumber(apr22Mode9NoLast, "GLMS-MARSK-20260421-FICC-03-180205IB"),
WithTradeNumber(apr22Mode2WithLast, "GLMS-MARSK-20260421-FICC-04-180205IB"),
WithTradeNumber(apr21SimpleWithLast, "GLMS-MARSK-20260421-FICC-05-180205IB"),
JiattCase("GLMS-JIATT-20260805-FICC-01-2180120IB", 30041492.070122881942m,
10019.043756537721m, 2970.02m, 10019.04105m),
JiattCase("GLMS-JIATT-20260727-FICC-02-2180120IB", 30044833.3381m,
13360.932596m, 5197.53m, 13360.93051m)
};
}
private static CloseCase AprCase(string tradeNumber, DateTime startDate, string calcMode,
int settlementRules, int interestMode, int interestType, int interestRule,
decimal fixedRate, decimal previousPrincipal, decimal previousPending,
decimal previousFloatRate, decimal closeFloatRate, decimal partialInterest,
decimal expectedFinal)
{
return new CloseCase
{
TradeNumber = tradeNumber,
StartDate = startDate,
CloseDate = new DateTime(2026, 5, 19),
InterestCalcMode = calcMode,
SettlementRules = settlementRules,
InterestMode = interestMode,
InterestType = interestType,
ResetDays = 7,
InterestRule = interestRule,
FixedRate = fixedRate,
PreviousPrincipal = previousPrincipal,
PreviousPendingInterest = previousPending,
PreviousFloatRate = previousFloatRate,
CloseFloatRate = closeFloatRate,
OriginalNotional = 303139117.80m,
RemainingNotional = 212197382.46m,
InitialQuantity = 300000000m,
PartialCloseQuantity = 90000000m,
PartialCloseInterest = partialInterest,
ExpectedFinalInterest = expectedFinal
};
}
private static CloseCase JiattCase(string tradeNumber, decimal remainingPrincipal,
decimal previousPending, decimal partialInterest, decimal expectedFinal)
{
return new CloseCase
{
TradeNumber = tradeNumber,
StartDate = new DateTime(2026, 7, 28),
CloseDate = new DateTime(2026, 8, 7),
InterestCalcMode = "10",
SettlementRules = 0,
InterestMode = 9,
InterestType = 1,
ResetDays = 7,
InterestRule = -1,
FixedRate = 0.001234m,
PreviousPrincipal = remainingPrincipal,
PreviousPendingInterest = previousPending,
PreviousFloatRate = 0.0213m,
CloseFloatRate = 0.0213m,
OriginalNotional = 50061728.39m,
RemainingNotional = remainingPrincipal,
InitialQuantity = 50000000m,
PartialCloseQuantity = 20000000m,
PartialCloseInterest = partialInterest,
ExpectedFinalInterest = expectedFinal
};
}
private static CloseCase WithTradeNumber(CloseCase source, string tradeNumber)
{
return new CloseCase
{
TradeNumber = tradeNumber,
StartDate = source.StartDate,
CloseDate = source.CloseDate,
InterestCalcMode = source.InterestCalcMode,
SettlementRules = source.SettlementRules,
InterestMode = source.InterestMode,
InterestType = source.InterestType,
ResetDays = source.ResetDays,
InterestRule = source.InterestRule,
FixedRate = source.FixedRate,
PreviousPrincipal = source.PreviousPrincipal,
PreviousPendingInterest = source.PreviousPendingInterest,
PreviousFloatRate = source.PreviousFloatRate,
CloseFloatRate = source.CloseFloatRate,
OriginalNotional = source.OriginalNotional,
RemainingNotional = source.RemainingNotional,
InitialQuantity = source.InitialQuantity,
PartialCloseQuantity = source.PartialCloseQuantity,
PartialCloseInterest = source.PartialCloseInterest,
ExpectedFinalInterest = source.ExpectedFinalInterest
};
}
}
}