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 { /// /// 【缺陷证明测试】界面「全部平仓 → 部分平仓」,改变平仓比例,下方利息腿数据不变。 /// ------------------------------------------------------------------------------ /// 现象(前端):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 成正比。 /// [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 }; } /// 固定利率利息腿(不走浮动曲线),仅 InterestMode 不同 private static swap_position CreateLeg(int interestMode, InterestTypeEnum interestType) { var intervalModels = new List { 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) }; } /// /// 复刻 GetUnwindInterests(SwapDealService.cs:640-645) 的入参构造, /// 唯一变量是界面输入的 closePercent。 /// 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 new List { 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 /// 与 CallProductionPath 相同,但允许指定平仓日期(用于两段式生命周期) 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(), new List { 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「未测」缺口) /// /// 覆盖「国联民生-债券TRS期间结算功能测试260702.xlsx」Sheet1 矩阵中唯一标记为「未测」的格子: /// 业务场景4-2(固定值腿,部分平仓一次后经过数日再全平)。 /// 同时守护 d1badfe4 的固定值腿平仓比例缩放修复—— /// 修复前 newClosePercent=1,部分平仓利息与全平完全相同(界面改比例利息腿不变)。 /// 本测试断言两段利息各自随平仓比例线性缩放(30% / 70%),正是该场景的回归守卫。 /// [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(); 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 的比例语义 /// /// 反射直击私有方法 CalcNotionalByMode,剥离所有计息细节, /// 只看「(closePrincipal, newClosePercent) 是否体现了平仓比例」。 /// 注意:closePrincipal 与 newClosePercent 作用在**不同项**上—— /// closePrincipal 决定"本期新增利息"的基数,newClosePercent 决定"历史累计/已消耗利息"的缩放, /// 二者不相乘(相乘会得到双重缩放的错误度量)。因此这里分别断言: /// 1) closePrincipal 应 = Fix × pct /// 2) newClosePercent 应 = pct(而非被强制置 1) /// [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 } }