- DealInterests 的 posiLongNotionalValue + posiShortNotionalValue 合并为 posiTotalNotional(调用点以 posiLongNotional+posiShortNotional 求和传入),净减一个参数 - SwapDealService / SwapEodPositionService / InterestCalcRequest 同步收敛多空死管道参数 - 19 个测试调用点适配新签名 - SwapEodPositionServiceIntegrationTest 参数计数断言由裸数字改为参数名集合断言(CollectionAssert.AreEquivalent,对增删/重排/改名敏感)
359 lines
16 KiB
C#
359 lines
16 KiB
C#
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, 1m, (int)SwapEventTypeEnum.平仓,
|
|
false,
|
|
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
|
|
};
|
|
}
|
|
}
|
|
}
|