diff --git a/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs b/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs new file mode 100644 index 00000000..774059a2 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs @@ -0,0 +1,269 @@ +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 + + #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 + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs index e1124ec7..557bdb25 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayPrincipalBugTdd.cs @@ -535,13 +535,17 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "合约名义本金规模 50% 应=posiNotional×0.5"); } - // ---- 固定值(1):CalcNotionalByMode 强制 newClosePercent=1,对 closePercent 免疫(输入 0.5 也不缩放) ---- + // ---- 固定值(1):修复后应与所有其他模式一致,按平仓比例线性缩放 ---- + // 历史缺陷:CalcNotionalByMode 对固定值腿同时把 closePrincipal 钉成 Fix 全量、newClosePercent 强制置 1, + // 导致「全部平仓→部分平仓」改比例时利息腿数据完全不变(与用户预期及其他模式行为不符,GLMS 缺陷)。 + // 修复见 SwapDealService.cs CalcNotionalByMode 固定值分支(引入提交 95686f4c,吴方海,EQD-5718,2026-05-08)。 + // 旧断言「对平仓比例免疫、恒=Fix」本身就是在保卫该缺陷,此处纠正为正确的线性缩放。 [TestMethod] - public void 固定值腿_盘中_部分平仓_对平仓比例免疫_返回Fix本金() + public void 固定值腿_盘中_部分平仓_应随平仓比例线性缩放() { const decimal baseP = 2_000_000m; var fe = CalcByMode((int)InterestModeEnum.固定值, baseP, 0.5m); - Assert.AreEqual(baseP, fe.InterestPrincipal, "固定值腿 newClosePercent=1,InterestPrincipal 恒=Fix,不随平仓比例缩放"); + Assert.AreEqual(1_000_000m, fe.InterestPrincipal, "固定值腿修复后 50% 应=Fix×0.5(与其他模式一致,缺陷已修复)"); } // ---- 日终路径(settment=true):证明走 CalcDailySimpleInterestByEod,结果恒为线性 closePrincipal,不受盘中 bug 影响 ---- diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 94f6b0a1..d6e6ecfe 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -899,8 +899,18 @@ namespace YLErp.Modules.SwapModule switch ((InterestModeEnum)position.InterestMode) { case InterestModeEnum.固定值: - closePrincipal = posiPrincipal = position.InterestPrincipalFix; - newClosePercent = 1m; + // 【缺陷修复】界面「全部平仓→部分平仓」改比例,下方利息腿数据完全不变。 + // 原实现:closePrincipal = posiPrincipal = Fix; newClosePercent = 1m; + // 固定值腿的"计息本金"确实固定(Fix),但"本次平仓结算/返还多少利息"必须按平仓比例缩放。 + // 旧代码把 closePrincipal 与 newClosePercent 两个缩放入口同时抹平, + // 导致 GetUnwindInterests 盘中预览无论传 30%/50%/70%/100%,返回利息完全相同。 + // 修复口径:与 初始/追加预付金 腿(下方 case)以及 标的期初全价(mode 9) 完全一致—— + // closePrincipal 承担"本期新增利息"的缩放,newClosePercent 承担"历史累计/已消耗利息"的缩放, + // 二者作用在不同项上,不会双重缩放(mode 9 已由回归测试证明线性)。 + // 兼容性:EOD 结算路径 SwapEodPositionService:1350 传 closePrecent = 1 字面量, + // 此处 Fix × 1 = Fix,日终行为与修复前完全一致。 + closePrincipal = position.InterestPrincipalFix * closePercent; + posiPrincipal = position.InterestPrincipalFix; break; case InterestModeEnum.多头存续名义本金: closePrincipal = posiLong * closePercent;