Files
zszq-trs/UnitTestProject/Modules/SwapModule/SwapCloseConversationCasesRegressionTest.cs
T
hjhan 4a3fee9292 refactor(swap)+test: 删 GetInterests/CalcSwapInterests 死参数 needPrice/grossPrice;补工厂→接缝映射钉子
死参数收口(另一半):
- SwapDealService.GetInterests 删 needPrice/grossPrice(体内零消费,2026-08 验证);
  InitSwapDealInterest.needPrice 同为死参数一并删
- SwapEodPositionService.CalcSwapInterests 签名+转发同步;两个 EOD 生产调用点
  (SaveAutoEodInterestPosition/SaveEodInterestPositionCopy) 重排实参;
  CalcEodPostCloseSettleInterests/GetIntradayUnwindInterests 委托同步
- 14 个测试文件 ~44 处直调点机械更新(8 处 override 签名 + 36 处调用实参)
- 注意:EOD 编排链(DealInterests→Save*家族)的 grossPrice(期初不含费价)有真实用途,保留未动

新增钉子:CalcEodPostCloseSettleInterests 工厂→接缝参数映射测试——
CalcSwapInterestsCapture 捕获 stub 断言 EodPostCloseSettle 的完整转发契约
(posi=平仓后剩余/closePosi=平掉额/恒1/settment:false/orginPv 等 11 项)。
该段位置转发含三个相邻同型 decimal,编译器不查错位,此测试兜底。

验证:定向 241 测试通过(含 T0/T1 Excel 验证期望值、EntrySemantics 精确值钉子——
任何 decimal 错位即红);全量 903=145失败/746通过/12跳过,与基线逐位一致。
2026-08-14 16:57:56 +08:00

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, 0m,
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
};
}
}
}