fix(swap): 修复复利重置日部分平仓后本金计算逻辑
- 修复复利重置日部分平仓后当日利息计算,使用已结转待实现利息的剩余复利本金 - 修复算尾部分平仓后EOD保留剩余复利本金的逻辑,按计息模式返回口径处理 - 修复模式2和模式9在部分平仓时的本金计算差异,避免重复比例调整 - 修复不算尾情况下重置日后已并入本金的待实现利息遗漏问题 - 新增对话案例回归测试验证部分平仓后最终全平场景的正确性 - 添加多种计息模式和交易类型的回归测试用例
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user