Files
zszq-trs/UnitTestProject/Modules/SwapModule/SwapFixedInterestLegClosePercentBugTest.cs
T
hjhan d1badfe48f fix(swap): 固定值利息腿平仓比例缩放缺陷(GLMS) + 证明测试
- SwapDealService.CalcNotionalByMode 固定值分支原实现把 closePrincipal 钉成 Fix 全量
  且 newClosePercent 强制置 1,导致界面『全部平仓→部分平仓』改比例时利息腿数据
  完全不变。修复为 closePrincipal = InterestPrincipalFix * closePercent(与预付金/
  多头/空头/标的期初全价等模式一致),posiPrincipal 仍保留 Fix 全量,newClosePercent
  保留真实平仓比例,两个缩放入口作用于不同项不双重缩放。
- EOD 结算路径 SwapEodPositionService 传 closePrecent=1 字面量,Fix*1=Fix,日终行为不变。
- 新增 SwapFixedInterestLegClosePercentBugTest(9 例真实函数复现+证明)。
- 纠正 SwapUnwindPrepayPrincipalBugTdd 中把『免疫』当期望的断言为线性缩放。
- 引入溯源:95686f4c(吴方海, EQD-5718, 2026-05-08);newClosePercent=1 祖传自 acbd2e76(2024-06-04)。
2026-08-08 10:34:10 +08:00

270 lines
13 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
#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
}
}