test(swap): 增加 GLMS-20260703-0002 平仓利息复现用例(真实FR007+真实函数)

- 调用系统 GetInterests 真实路径(复利/单利两条), 用截图中的真实 FR007 定盘值
  在 07-06/07-13 两个重置点真实取数, 复现系统值 -13,667.85 与业务实际值 -13,668.02
- 断言两者差异 0.17 元来自复利第二段本金并入
- 仅保留调用系统函数的断言, 不内嵌手算, 验算见 GLMS20260703_平仓利息验算.xlsx
This commit is contained in:
hjhan
2026-07-20 18:31:19 +08:00
parent f4ffe71071
commit 98bfe8fb5d
@@ -0,0 +1,316 @@
using Newtonsoft.Json;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// GLMS-20260703-0002 债券TRS 平仓利息差异 - 仅调用系统函数复现
/// ============================================================================
/// 客户测试环境这笔债券TRS(截图确认):
/// 成交/持仓名义本金 = 279,486,108.21
/// 起息日(StartDate=SettleDate) = 2026-07-06,平仓日(UnwindDate) = 2026-07-20
/// 年化天数 = 365,计息模式 = 算头不算尾("10"),计息天数 = 14 天
/// 利率 = FR007-1.55%(浮动利率腿),重置频率 = 7 天,interest_rule = 0(当前营业日)
/// 截图 FR00707-06=1.42%, 07-13=1.425%(重置点取当前营业日)
///
/// 观察结果:
/// 系统计算的平仓盈亏(利息端)= -13,667.85
/// 实际应得平仓盈亏(利息端)= -13,668.02
/// 差异 = 0.17 元
///
/// 代码走查结论(SwapDealService):
/// 平仓路径:GetInterests → CalcSwapDealInterest → CalcUnwindInterest
/// → InitSwapDealInterest → CalcDailyCompoundInterest / CalcDailySimpleInterest
/// FR007 取数规则:只在"重置日"(i % interest_rest_days == 0) 取一次 FR007,非重置日沿用上一重置日。
/// 本例 07-06~07-20 跨 2 个重置周期,取 2 次 FR007:
/// - 第一段(07-06~07-12):取 07-06 当前营业日 FR007 = 1.42%
/// - 第二段(07-13~07-19):取 07-13 当前营业日 FR007 = 1.425%
/// 差异根因:系统配置走 <复利>,导致 07-13 重置日把前 7 天累计利息并入本金,
/// 第二段计息本金降为 279,479,140.20,最终利息绝对值比单利少 0.17 元。
/// 若按业务口径走 <单利>,则本金全程保持 279,486,108.21,利息 = -13,668.02。
///
/// 本测试不再做任何手工计算(手算/验算见同目录 Excel:GLMS20260703_平仓利息验算.xlsx),
/// 只做:构造输入 → 调用系统真实函数 GetInterests → 与截图已知结果断言。
/// ============================================================================
[TestClass]
public class GLMS20260703CloseInterestTest
{
#region DB FR007
private sealed class StubSwapDealService : SwapDealService
{
public StubSwapDealService(OptUserInfo optUser) : base(optUser) { }
/// <summary>单元测试记录:生产代码每次调用 TryGetFloatRate 的日期与结果。</summary>
public readonly List<(DateTime RequestDate, double Rate)> FloatRateCalls = new();
/// <summary>
/// 按截图 eod_commodity_future_price.ValueDate 返回 FR007 ReferencePrice(小数)。
/// 生产环境由 TryGetFloatRate 去行情/DB 取数;单元测试用截图硬编码快照替代。
/// </summary>
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
rate = 0d;
if (underlyingCode != "FR007") return false;
var fr007 = new Dictionary<DateTime, double>
{
[new DateTime(2026, 7, 3)] = 0.0143,
[new DateTime(2026, 7, 6)] = 0.0142,
[new DateTime(2026, 7, 7)] = 0.0143,
[new DateTime(2026, 7, 8)] = 0.0143,
[new DateTime(2026, 7, 9)] = 0.0143,
[new DateTime(2026, 7, 10)] = 0.0142,
[new DateTime(2026, 7, 13)] = 0.01425,
[new DateTime(2026, 7, 14)] = 0.0143,
[new DateTime(2026, 7, 15)] = 0.0144,
[new DateTime(2026, 7, 16)] = 0.0144,
[new DateTime(2026, 7, 17)] = 0.0144,
[new DateTime(2026, 7, 20)] = 0.0143,
};
if (fr007.TryGetValue(valueDate.Date, out rate))
{
FloatRateCalls.Add((valueDate.Date, rate));
return true;
}
// 若请求日期不在硬编码表(如 interest_rule=-1 调到周末),返回最近有值日的 FR007
var nearest = fr007.Keys.OrderByDescending(d => d)
.FirstOrDefault(d => d <= valueDate.Date);
if (nearest != default)
{
rate = fr007[nearest];
FloatRateCalls.Add((valueDate.Date, rate));
return true;
}
return false;
}
}
#endregion
#region GLMS-20260703-0002
/// <summary>成交/持仓名义本金</summary>
private const decimal Notional = 279486108.21m;
/// <summary>年化天数</summary>
private const int AnnualDays = 365;
/// <summary>固定利差 -1.55%FR007-1.55%</summary>
private const decimal Spread = -0.0155m;
/// <summary>起息日(td.StartDate = SettleDate,不是成交日)</summary>
private static readonly DateTime StartDate = new(2026, 7, 6);
/// <summary>成交日(td.TradeDate,仅作对照;不参与计息起点)</summary>
private static readonly DateTime TradeDate = new(2026, 7, 3);
/// <summary>平仓日(valueDate / unwindDate</summary>
private static readonly DateTime CloseDate = new(2026, 7, 20);
/// <summary>系统实际计算的利息绝对值</summary>
private const decimal SystemInterestAmount = 13667.85m;
/// <summary>实际应得利息绝对值</summary>
private const decimal ActualInterestAmount = 13668.02m;
#endregion
private SwapDealService _service;
[TestInitialize]
public void Init()
{
_service = new StubSwapDealService(
new OptUserInfo(0, nameof(GLMS20260703CloseInterestTest), OptUserFrom.UnitTest));
}
#region
private static trade CreateTrade()
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10", // 算头不算尾
SettlementRules = 0
})
};
return new trade
{
id = 1,
TradeNumber = "GLMS-20260703-0002",
ClientId = 999998,
TradeType = "债券TRS",
TradeDate = TradeDate,
StartDate = StartDate,
ExerciseDate = CloseDate.AddDays(1), // 必须 > valueDate,否则 InitInterestDate 会多减一天
TradeStatus = "已平仓",
ValidState = "Valid",
trade_extend = extend
};
}
/// <summary>
/// 构造债券TRS浮动利率本金腿。FloatRateUnderlyingCode=FR007,利率=FR007-1.55%。
/// 仅设置输入字段;利息由系统函数 CalcDailyCompoundInterest / CalcDailySimpleInterest 计算。
/// </summary>
private static swap_position CreateBondPosition(InterestTypeEnum interestType, int interestRule = 0)
{
var intervalModels = new List<IntervalModel>
{
new IntervalModel { Date = CloseDate, Rate = Spread, Settlement = 0 }
};
return new swap_position
{
id = 1001,
SwapTradeId = 1,
PositionType = (int)PositionTypeFlag.Unknown,
// 债券本金腿:标的期初全价(closePrincipal = posiNotional × closePercent
InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum.,
InterestRateDefault = Spread,
InterestPrincipalFix = Notional,
PosiStartDate = StartDate,
PosiMatuirityDate = CloseDate,
IsInitial = true,
Invalid = false,
InterestType = (int)interestType, // 复利 or 单利
IsAnnualized = true,
interest_rest_days = 7, // 7天重置
interest_rule = interestRule, // 0=当前营业日, -1=前一营业日
FloatRateUnderlyingCode = "FR007", // 真实浮动利率腿
FloatRate = 0m,
PosiNotionalValue = Notional,
UnderlyingCode = "511160.SH",
InterestSwapInterval = JsonConvert.SerializeObject(intervalModels)
};
}
/// <summary>
/// 驱动真实平仓利息路径(settment=false → CalcUnwindInterest → InitSwapDealInterest
/// → CalcDailyCompoundInterest / CalcDailySimpleInterest)。
/// eodPositions 传空 → 等效"无前日日终快照",日循环从起始日重算。
/// </summary>
private swap_flow_event CalcCloseInterest(InterestTypeEnum interestType, int interestRule = 0)
{
var td = CreateTrade();
var position = CreateBondPosition(interestType, interestRule);
// 每次计算前清空取数记录,避免同一测试中多次调用互相污染
((StubSwapDealService)_service).FloatRateCalls.Clear();
var interests = _service.GetInterests(
td, td.trade_extend,
CloseDate, CloseDate, // valueDate / unwindDate
new List<eod_swap_position>(), // eodPositions(空)
new List<swap_position> { position },
Notional, Notional, Notional, Notional, // posiNotional / long / short / closePosiNotional
1m, // closePercent
(int)SwapEventTypeEnum.,
false, false, 0m, Notional, // tdClose / needPrice / grossPrice / orginPv
false, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, interests.Count);
return interests[0];
}
#endregion
#region +
/// <summary>
/// [GLMS20260703_REPRO_001] 系统路径:InterestType=复利 + 真实FR007 → 复现系统值 -13,667.85
/// 与生产代码完全一致:7天重置,重置日取当前营业日 FR007,利息并入本金。
/// </summary>
[TestMethod]
public void Reproduce_SystemValue_13667_85_WithCompoundInterest()
{
var interest = CalcCloseInterest(InterestTypeEnum.);
var amount2 = Math.Round(interest.InterestAmount, 2, MidpointRounding.AwayFromZero);
var closePnl2 = Math.Round(interest.InterestClosePnL, 2, MidpointRounding.AwayFromZero);
Console.WriteLine("系统路径(复利):");
Console.WriteLine($" 利息金额(2位显示)={amount2}");
Console.WriteLine($" 平仓盈亏(利息端,2位)={closePnl2}");
// 截图给的是"平仓盈亏(利息端)"的绝对值口径,故比较绝对值。
Assert.AreEqual(SystemInterestAmount, Math.Abs(amount2),
$"系统复利路径应得到利息绝对值 {SystemInterestAmount},实际 {amount2}");
Assert.AreEqual(SystemInterestAmount, Math.Abs(closePnl2),
$"支付方向平仓盈亏绝对值应为 {SystemInterestAmount},实际 {closePnl2}");
}
/// <summary>
/// [GLMS20260703_REPRO_002] 实际口径:InterestType=单利 + 真实FR007 → 复现实际值 -13,668.02
/// 同一 position、同一 FR007、同一计息天数,仅把 InterestType 改为单利,
/// 利息不并入本金,全程用名义本金 279,486,108.21 计息。
/// </summary>
[TestMethod]
public void Reproduce_ActualValue_13668_02_WithSimpleInterest()
{
var interest = CalcCloseInterest(InterestTypeEnum.);
var amount2 = Math.Round(interest.InterestAmount, 2, MidpointRounding.AwayFromZero);
var closePnl2 = Math.Round(interest.InterestClosePnL, 2, MidpointRounding.AwayFromZero);
Console.WriteLine("实际口径(单利):");
Console.WriteLine($" 利息金额(2位显示)={amount2}");
Console.WriteLine($" 平仓盈亏(利息端,2位)={closePnl2}");
Assert.AreEqual(ActualInterestAmount, Math.Abs(amount2),
$"单利路径应得到利息绝对值 {ActualInterestAmount},实际 {amount2}");
Assert.AreEqual(ActualInterestAmount, Math.Abs(closePnl2),
$"支付方向平仓盈亏绝对值应为 {ActualInterestAmount},实际 {closePnl2}");
}
/// <summary>
/// [GLMS20260703_REPRO_003] 差异定位:0.17 元 = 复利 vs 单利
/// 同一笔交易、同一 FR007 取值、同一计息天数,唯一区别是 InterestType
/// 系统(复利)比实际(单利)少 0.17 元。
/// </summary>
[TestMethod]
public void PrecisionGap_Is_0_17_ComplexVsSimple()
{
var compound = CalcCloseInterest(InterestTypeEnum.).InterestAmount;
var simple = CalcCloseInterest(InterestTypeEnum.).InterestAmount;
// 两者都是负数(支付方向),取绝对值差异
var gap = Math.Round(Math.Abs(simple) - Math.Abs(compound), 2, MidpointRounding.AwayFromZero);
Console.WriteLine($"复利 |利息|={Math.Abs(compound):F11}");
Console.WriteLine($"单利 |利息|={Math.Abs(simple):F11}");
Console.WriteLine($"差异(2位)={gap}");
Assert.AreEqual(0.17m, gap, "单利与复利的利息绝对值差异应为 0.17 元");
}
/// <summary>
/// [GLMS20260703_REPRO_004] 验证真实函数确实在 07-06、07-13 两个重置点取了 FR007
/// (不手算利息,仅检查系统函数实际发起了哪几次取数)。
/// </summary>
[TestMethod]
public void Trace_FloatRate_Taken_Dates()
{
var fe = CalcCloseInterest(InterestTypeEnum., interestRule: 0);
var calls = ((StubSwapDealService)_service).FloatRateCalls;
Console.WriteLine("TryGetFloatRate 实际调用记录(按调用顺序):");
foreach (var (date, rate) in calls)
{
Console.WriteLine($" 请求日期={date:yyyy-MM-dd} 返回 FR007={rate:P4}");
}
Assert.IsTrue(calls.Any(c => c.RequestDate == new DateTime(2026, 7, 6)), "应取 07-06 的 FR007");
Assert.IsTrue(calls.Any(c => c.RequestDate == new DateTime(2026, 7, 13)), "应取 07-13 的 FR007");
Assert.AreEqual(SystemInterestAmount, Math.Abs(Math.Round(fe.InterestAmount, 2, MidpointRounding.AwayFromZero)),
"interest_rule=0 复利应复现系统值 -13,667.85");
}
#endregion
}
}