Files
zszq-trs/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs
T
hjhan 5ca297a73c test(swap): 补充固定值腿业务场景4-2两段式平仓覆盖(GLMS-债券TRS期间结算260702缺口)
- 新增 业务场景4_2_固定值腿_部分平仓后再全平_两段利息均随比例线性缩放
  覆盖「国联民生-债券TRS期间结算功能测试260702.xlsx」Sheet1 矩阵中唯一标记为
  「未测」的格子:固定值腿(mode=1) 部分平仓一次后隔数日再全平。
- 断言两段利息各自随平仓比例线性缩放(30%/70%对照同日期全平),守护 d1badfe4
  的固定值腿平仓比例缩放修复;修复前 newClosePercent=1 会让部分平仓利息等于全平。
- 现有利息测试 75 例全绿(场景1/2/3/4 + 单笔 + 固定值腿 + 多步守恒)。
- 0601 缺陷测试-利息(场景1-4 + 单笔0007/0006) 与 20260807 同源,已由上述套件覆盖。
2026-08-08 12:26:27 +08:00

342 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using YLErp;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Modules.SwapModule;
namespace UnitTestProject.Modules.SwapModule
{
/// <summary>
/// 【缺陷证明测试】界面「全部平仓 → 部分平仓」,改变平仓比例,下方利息腿数据不变。
/// ------------------------------------------------------------------------------
/// 现象(前端):SwapUnwind.cshtml 的 ClosePercent 输入框 → changeClosePercent →
/// getInterestList → POST /swaptrade2/GetUnwindInterestList → 后端返回的利息腿列表数值不随比例变化。
///
/// 根因(后端,本测试要钉死的):SwapDealService.CalcNotionalByMode
/// case InterestModeEnum.固定值:
/// closePrincipal = posiPrincipal = position.InterestPrincipalFix;
/// newClosePercent = 1m; // ← 这一行把平仓比例直接抹成 1
/// 固定值利息腿的计息本金恒等于 InterestPrincipalFix(与比例无关),
/// 同时 newClosePercent 被强制置 1,后续 CalcDailySimpleInterest / CalcDailyCompoundInterest
/// 拿到的 closePercent 也是 1 → 无论前端传 30% / 50% / 70% / 100%,返回利息完全相同。
///
/// 对照组:InterestModeEnum.标的期初全价(9) 走 closePrincipal = posiNotional * closePercent
/// 线性缩放,作为「正确行为」的基准。若对照组也不变,说明问题不在本函数(排除法)。
///
/// 调用方式:完全复刻生产 GetUnwindInterests(SwapDealService.cs:640-645) 的入参构造——
/// posiNotionalValue = stockEqvNotional(剩余名义本金,不乘比例)
/// posiLongNotionalValue = 多头剩余
/// closePosiNotionalValue = stockEqvNotional * closePercent
/// closePrecent = closePercent(控制器已做 A→B 口径转换)
/// add=true, settment=false(盘中预览,不落库)
/// 因此这是**真实生产函数**的行为,不是夹具自造逻辑。
///
/// 期望(修复前 红 / 修复后 绿):固定值腿利息应与 closePercent 成正比。
/// </summary>
[TestClass]
public class SwapFixedInterestLegClosePercentBugTest
{
private const decimal Notional = 100_000_000m; // 期初=剩余名义本金 1 亿
private const decimal FixedRate = 0.03m; // 固定年利率 3%
private const int AnnualDays = 365;
private static readonly DateTime StartDate = new DateTime(2026, 4, 21);
private static readonly DateTime Maturity = new DateTime(2026, 6, 30);
private static readonly DateTime CloseDate = new DateTime(2026, 5, 11);
#region Stub(无库)
private sealed class StubSvc : SwapDealService
{
public StubSvc() : base(new OptUserInfo(0, nameof(SwapFixedInterestLegClosePercentBugTest), OptUserFrom.UnitTest)) { }
// 无库环境:已消耗利息视为 0
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m;
}
#endregion
#region 构造器
private static trade CreateTrade(string interestCalcMode)
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = interestCalcMode,
SettlementRules = 0
})
};
return new trade
{
id = 1,
TradeNumber = "UT-CLOSEPCT-BUG",
ClientId = 999998,
TradeType = "收益互换",
TradeDate = StartDate,
StartDate = StartDate,
ExerciseDate = Maturity,
TradeStatus = "确认成交",
ValidState = "Valid",
trade_extend = extend
};
}
/// <summary>固定利率利息腿(不走浮动曲线),仅 InterestMode 不同</summary>
private static swap_position CreateLeg(int interestMode, InterestTypeEnum interestType)
{
var intervalModels = new List<IntervalModel>
{
new IntervalModel { Date = Maturity, Rate = FixedRate, Settlement = 0 }
};
return new swap_position
{
id = 1001,
SwapTradeId = 1,
PositionType = (int)PositionTypeFlag.Unknown,
PosiDirection = 0, // 利息腿
InterestDirection = (int)SwapDirectionEnum.收取,
InterestMode = interestMode,
InterestRateDefault = FixedRate,
InterestPrincipalFix = Notional, // 固定值腿的计息本金
PosiStartDate = StartDate,
PosiMatuirityDate = Maturity,
IsInitial = true,
Invalid = false,
InterestType = (int)interestType,
IsAnnualized = true,
interest_rest_days = 1,
interest_rule = 0,
FloatRateUnderlyingCode = null, // 固定利率
InterestSwapInterval = JsonConvert.SerializeObject(intervalModels)
};
}
/// <summary>
/// 复刻 GetUnwindInterests(SwapDealService.cs:640-645) 的入参构造,
/// 唯一变量是界面输入的 closePercent。
/// </summary>
private static swap_flow_event CallProductionPath(trade td, swap_position leg, decimal closePercent)
{
var svc = new StubSvc();
var stockEqvNotional = Notional; // 剩余名义本金
var posiLongNotionalValue = Notional;
var posiShortNotionalValue = 0m;
var closePosiNotionalValue = stockEqvNotional * closePercent; // 生产:posiNotionalValue = stockEqvNotional * closePercent
var orginPv = stockEqvNotional;
var interests = svc.GetInterests(
td, td.trade_extend, CloseDate, CloseDate,
new List<eod_swap_position>(), // 无上一日 EOD
new List<swap_position> { leg },
stockEqvNotional,
posiLongNotionalValue,
posiShortNotionalValue,
closePosiNotionalValue,
closePercent,
(int)SwapEventTypeEnum.平仓,
tdClose: false,
needPrice: false,
grossPrice: 0m,
orginPv: orginPv,
add: true,
settment: false);
Assert.AreEqual(1, interests.Count, "应返回 1 条利息腿");
return interests[0];
}
#endregion
/// <summary>与 CallProductionPath 相同,但允许指定平仓日期(用于两段式生命周期)</summary>
private static swap_flow_event CallWithDate(trade td, swap_position leg, decimal closePercent, DateTime closeDate)
{
var svc = new StubSvc();
var stockEqvNotional = Notional;
var posiLongNotionalValue = Notional;
var posiShortNotionalValue = 0m;
var closePosiNotionalValue = stockEqvNotional * closePercent;
var orginPv = stockEqvNotional;
var interests = svc.GetInterests(
td, td.trade_extend, closeDate, closeDate,
new List<eod_swap_position>(),
new List<swap_position> { leg },
stockEqvNotional,
posiLongNotionalValue,
posiShortNotionalValue,
closePosiNotionalValue,
closePercent,
(int)SwapEventTypeEnum.平仓,
tdClose: false,
needPrice: false,
grossPrice: 0m,
orginPv: orginPv,
add: true,
settment: false);
Assert.AreEqual(1, interests.Count, "应返回 1 条利息腿");
return interests[0];
}
#region 业务场景4-2:固定值腿 部分平仓后再全平(两段式,GLMS-债券TRS期间结算 260702「未测」缺口)
/// <summary>
/// 覆盖「国联民生-债券TRS期间结算功能测试260702.xlsx」Sheet1 矩阵中唯一标记为「未测」的格子:
/// 业务场景4-2(固定值腿,部分平仓一次后经过数日再全平)。
/// 同时守护 d1badfe4 的固定值腿平仓比例缩放修复——
/// 修复前 newClosePercent=1,部分平仓利息与全平完全相同(界面改比例利息腿不变)。
/// 本测试断言两段利息各自随平仓比例线性缩放(30% / 70%),正是该场景的回归守卫。
/// </summary>
[DataTestMethod]
[DataRow("11")] // 算头算尾
[DataRow("10")] // 算头不算尾
public void 业务场景4_2_固定值腿_部分平仓后再全平_两段利息均随比例线性缩放(string calcMode)
{
var td = CreateTrade(calcMode);
var leg = CreateLeg((int)InterestModeEnum.固定值, InterestTypeEnum.单利);
// 单步全平(100%)作为基准
var full = CallProductionPath(td, leg, 1m);
Assert.AreNotEqual(0m, full.InterestAmount, "全平利息不应为 0,否则用例无区分度");
var partialDate = new DateTime(2026, 5, 11);
// 第一段:部分平仓 30%(较早日期 2026-05-11
var i30 = CallWithDate(td, leg, 0.3m, partialDate);
// 第二段:剩余 70% 全平(到期日,算尾)
var i70 = CallWithDate(td, leg, 0.7m, Maturity);
// 各段对照「同日期全平」基准:缩放必须在相同日期范围内比较
var fullAtPartial = CallWithDate(td, leg, 1m, partialDate);
var fullAtMaturity = CallWithDate(td, leg, 1m, Maturity);
Console.WriteLine($"[场景4-2 mode=1 calcMode={calcMode}] 5/11全平={fullAtPartial.InterestAmount} 30%={i30.InterestAmount} 到期全平={fullAtMaturity.InterestAmount} 70%={i70.InterestAmount}");
// 核心断言:两段利息必须各自随平仓比例线性缩放(守护 mode-1 修复,修复前会等于全平)
Assert.AreEqual((double)(fullAtPartial.InterestAmount * 0.3m), (double)i30.InterestAmount, 0.01,
"固定值腿第一段(30%)利息应≈同日期全平×30%(修复前会等于全平,即 Bug)");
Assert.AreEqual((double)(fullAtMaturity.InterestAmount * 0.7m), (double)i70.InterestAmount, 0.01,
"固定值腿第二段(70%)利息应≈同日期全平×70%");
}
#endregion
#region 对照组:标的期初全价(9) —— 正确行为,比例线性缩放
[DataTestMethod]
[DataRow("11")] // 算头算尾
[DataRow("10")] // 算头不算尾
public void 对照组_标的期初全价腿_利息应随平仓比例线性变化(string calcMode)
{
var td = CreateTrade(calcMode);
var full = CallProductionPath(td, CreateLeg((int)InterestModeEnum.标的期初全价, InterestTypeEnum.单利), 1m);
Console.WriteLine($"[对照组 mode=9 calcMode={calcMode}] 100% 利息 = {full.InterestAmount}");
Assert.AreNotEqual(0m, full.InterestAmount, "全平利息不应为 0,否则用例无区分度");
foreach (var pct in new[] { 0.3m, 0.5m, 0.7m })
{
var part = CallProductionPath(td, CreateLeg((int)InterestModeEnum.标的期初全价, InterestTypeEnum.单利), pct);
var expect = full.InterestAmount * pct;
Console.WriteLine($" {pct:P0} 实际={part.InterestAmount} 期望={expect} 差={part.InterestAmount - expect}");
Assert.AreEqual((double)expect, (double)part.InterestAmount, 0.01,
$"mode=9 应线性缩放:{pct:P0}");
}
}
#endregion
#region 缺陷组:固定值(1) —— 当前不随比例变化(修复前红)
[DataTestMethod]
[DataRow("11", 0)] // 算头算尾 + 单利
[DataRow("10", 0)] // 算头不算尾 + 单利
[DataRow("11", 1)] // 算头算尾 + 复利
[DataRow("10", 1)] // 算头不算尾 + 复利
public void 缺陷_固定值利息腿_利息必须随平仓比例线性变化(string calcMode, int typeFlag)
{
var type = typeFlag == 1 ? InterestTypeEnum.复利 : InterestTypeEnum.单利;
var td = CreateTrade(calcMode);
var full = CallProductionPath(td, CreateLeg((int)InterestModeEnum.固定值, type), 1m);
Console.WriteLine($"[缺陷组 mode=1(固定值) calcMode={calcMode} {type}] 100% 利息 = {full.InterestAmount}");
Assert.AreNotEqual(0m, full.InterestAmount, "全平利息不应为 0,否则用例无区分度");
var diffs = new List<string>();
var unchanged = 0;
foreach (var pct in new[] { 0.3m, 0.5m, 0.7m })
{
var part = CallProductionPath(td, CreateLeg((int)InterestModeEnum.固定值, type), pct);
var expect = full.InterestAmount * pct;
var delta = part.InterestAmount - expect;
if (part.InterestAmount == full.InterestAmount) unchanged++;
diffs.Add($" {pct:P0} 实际={part.InterestAmount} 期望={expect} 偏差={delta}");
}
diffs.ForEach(Console.WriteLine);
if (unchanged == 3)
{
Console.WriteLine(" ▲ 三个比例返回值与 100% 完全一致 → 复现「界面改比例利息腿数据不变」");
Console.WriteLine(" ▲ 根因:CalcNotionalByMode case 固定值 → newClosePercent = 1m");
}
foreach (var pct in new[] { 0.3m, 0.5m, 0.7m })
{
var part = CallProductionPath(td, CreateLeg((int)InterestModeEnum.固定值, type), pct);
Assert.AreEqual((double)(full.InterestAmount * pct), (double)part.InterestAmount, 0.01,
$"固定值腿应随比例缩放,但 {pct:P0} 与全平返回相同值(Bug");
}
}
#endregion
#region 单元级:直接断言 CalcNotionalByMode 的比例语义
/// <summary>
/// 反射直击私有方法 CalcNotionalByMode,剥离所有计息细节,
/// 只看「(closePrincipal, newClosePercent) 是否体现了平仓比例」。
/// <para>注意:closePrincipal 与 newClosePercent 作用在**不同项**上——
/// closePrincipal 决定"本期新增利息"的基数,newClosePercent 决定"历史累计/已消耗利息"的缩放,
/// 二者不相乘(相乘会得到双重缩放的错误度量)。因此这里分别断言:
/// 1) closePrincipal 应 = Fix × pct
/// 2) newClosePercent 应 = pct(而非被强制置 1</para>
/// </summary>
[DataTestMethod]
[DataRow((int)InterestModeEnum.固定值, "0.3")]
[DataRow((int)InterestModeEnum.固定值, "0.5")]
[DataRow((int)InterestModeEnum.固定值, "0.7")]
public void CalcNotionalByMode_固定值腿_有效缩放系数必须等于平仓比例(int mode, string pctStr)
{
var pct = decimal.Parse(pctStr, System.Globalization.CultureInfo.InvariantCulture);
var leg = CreateLeg(mode, InterestTypeEnum.单利);
var m = typeof(SwapDealService).GetMethod("CalcNotionalByMode",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.IsNotNull(m, "未找到 CalcNotionalByMode,签名可能已变更");
var full = m.Invoke(new StubSvc(), new object[] { leg, 1m, Notional, Notional, 0m });
var part = m.Invoke(new StubSvc(), new object[] { leg, pct, Notional, Notional, 0m });
var fullClose = (decimal)full.GetType().GetField("Item1").GetValue(full);
var fullPct = (decimal)full.GetType().GetField("Item3").GetValue(full);
var partClose = (decimal)part.GetType().GetField("Item1").GetValue(part);
var partPct = (decimal)part.GetType().GetField("Item3").GetValue(part);
Console.WriteLine($"[CalcNotionalByMode mode={mode}] 100%: closePrincipal={fullClose} newClosePercent={fullPct}");
Console.WriteLine($"[CalcNotionalByMode mode={mode}] {pct:P0}: closePrincipal={partClose} newClosePercent={partPct}");
// 1) 计息本金必须按比例缩放(本期新增利息的基数)
Assert.AreEqual((double)(fullClose * pct), (double)partClose, 0.01,
$"固定值腿 closePrincipal 应为 Fix×{pct:P0},实际未缩放");
// 2) 传给 CalcDaily*Interest 的比例必须是真实平仓比例(历史累计/已消耗利息的缩放)
Assert.AreEqual((double)pct, (double)partPct, 1e-9,
$"固定值腿 newClosePercent 应为 {pct:P0},被强制置 1 会抹掉比例语义");
}
#endregion
}
}