- 新增 业务场景4_2_固定值腿_部分平仓后再全平_两段利息均随比例线性缩放
覆盖「国联民生-债券TRS期间结算功能测试260702.xlsx」Sheet1 矩阵中唯一标记为
「未测」的格子:固定值腿(mode=1) 部分平仓一次后隔数日再全平。
- 断言两段利息各自随平仓比例线性缩放(30%/70%对照同日期全平),守护 d1badfe4
的固定值腿平仓比例缩放修复;修复前 newClosePercent=1 会让部分平仓利息等于全平。
- 现有利息测试 75 例全绿(场景1/2/3/4 + 单笔 + 固定值腿 + 多步守恒)。
- 0601 缺陷测试-利息(场景1-4 + 单笔0007/0006) 与 20260807 同源,已由上述套件覆盖。
342 lines
17 KiB
C#
342 lines
17 KiB
C#
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
|
||
}
|
||
}
|