Files
zszq-trs/UnitTestProject/Modules/SwapModule/ClosePercentProductContractTest.cs
T
hjhan e188ba5038 test(swap): 新增 ClosePercent 口径A产品契约测试,防止平仓比例被改为占剩余口径
新增前后端回归测试,锁定产品需求:ClosePercent 永远是占期初名义本金的比例。后端 CPC_001~010 装配测试验证 SwapDealService 真实执行 ToRemainingClosePercent/ToOriginalClosePercent 转换;前端 16 项接线测试验证 unwindSwapTrade.js 使用 NotionalValue 作为分母且 postData 携带 notionalValue 字段。测试背景:c9071a4e 曾将 ClosePercent 改为占剩余口径导致回归但现有测试全通过,本测试通过源码装配与契约验证弥补该缺口。
2026-07-27 13:18:32 +08:00

358 lines
21 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.
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 平仓比例口径A(占期初名义本金)产品契约测试
/// ================================================================================
/// 产品需求(不可违背):
/// ClosePercent 永远是"占期初名义本金(NotionalValue)的比例",即口径A。
/// 例:期初本金 5000 万,首次平 35%ClosePercent=0.35),剩余 3250 万。
/// 第二次想平掉剩余的 50%ClosePercent 应 = 1625万/5000万 = 0.325(占期初),
/// 而非 0.50(占剩余)。
///
/// 前后端协作约定:
/// - 前端(unwindSwapTrade.js):ClosePercent 始终以 NotionalValue 为分母计算/显示
/// - 前端传给后端:ClosePercent 为口径A
/// - 后端入口(SwapUnwind/ApplySwapTrade):ToRemainingClosePercent 将 A→B 供内部计算
/// - 后端落库(SaveSwapDealInternal):ToOriginalClosePercent 将 B→A 还原存储
/// - 后端 InitUnwind 默认值:CalcDefaultInitClosePercent = PosiNotionalValue/NotionalValue(口径A
///
/// 本测试守护的回归场景(c9071a4e 曾犯的错误):
/// 1. 前端把 oriClosePercent 硬编码为 1(应为 PosiNotionalValue/NotionalValue
/// 2. 前端把 CloseNotionalValue 分母从 NotionalValue 改为 PosiNotionalValue
/// 3. 前端把 ClosePercent 分母从 NotionalValue 改为 PosiNotionalValue
/// 4. 前端把 getInterestList 的 notionalValue/posiNotionalValue 参数去掉
/// 5. 前端把 calcCloseQtyByPercent 从 SwapCalc.calcCloseQtyByOriginalPercent 改为直接乘
/// 6. 后端 InitUnwind 默认 ClosePercent 改为 1 而非剩余比例
///
/// 与既有测试的关系:
/// - ApplySwapTradeClosePercentBugTest:测 A→B 转换函数正确性(函数级)
/// - InitUnwindDefaultClosePercentTest:测默认值函数正确性(函数级)
/// - 本测试:测完整多步场景的口径A契约(场景级),补齐"装配测试"盲区
/// ================================================================================
[TestClass]
public class ClosePercentProductContractTest
{
// GLMS-20260701-0006 真实数据
private const decimal OriginalNotional = 50_000_000m; // 期初名义本金(NotionalValue)
private const decimal FirstClosePercentA = 0.35m; // 第一次平仓35%(口径A
private const decimal RemainingAfter1st = 32_500_000m; // 首次平35%后剩余(PosiNotionalValue)
// 第二次想平掉剩余的 50% → 平仓额=16,250,000 → ClosePercent(A)=1625万/5000万=0.325
private const decimal SecondCloseNotional = 16_250_000m;
private const decimal SecondClosePercentA = 0.325m; // 口径A:占期初
private const decimal SecondClosePercentB = 0.50m; // 口径B:占剩余
// ================================================================
// 契约1ClosePercent = CloseNotionalValue / NotionalValue(口径A
// 如果有人把分母改成 PosiNotionalValue,此测试会红
// ================================================================
[TestMethod]
public void CPC_001_平仓比例必须用期初名义本金为分母_而非剩余名义本金()
{
// 正确:口径A = 平仓名义本金 / 期初名义本金
decimal correctA = SecondCloseNotional / OriginalNotional;
SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentA, correctA, 1e-10m,
"口径A16,250,000 / 50,000,000 = 0.325");
// 错误:口径B = 平仓名义本金 / 剩余名义本金
decimal buggyB = SecondCloseNotional / RemainingAfter1st;
SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentB, buggyB, 1e-10m,
"口径B(错误):16,250,000 / 32,500,000 = 0.50");
// 两者必须不同——如果相同说明测试场景退化(首次平仓 remaining==original
Assert.AreNotEqual(correctA, buggyB,
"口径A(0.325) 和 口径B(0.50) 在多次部分平仓后必须不同,否则测试场景退化");
Console.WriteLine($"口径A={correctA}(正确),口径B={buggyB}(错误)");
}
// ================================================================
// 契约2CloseNotionalValue = ClosePercent × NotionalValue(口径A
// 如果有人把乘数改成 PosiNotionalValue,此测试会红
// ================================================================
[TestMethod]
public void CPC_002_平仓名义本金必须用期初名义本金为乘数_而非剩余名义本金()
{
// 正确:口径A → CloseNotionalValue = ClosePercent(A) × NotionalValue
decimal correctNotional = SecondClosePercentA * OriginalNotional;
SwapDealTestFactory.AssertDecimalEqual(SecondCloseNotional, correctNotional, 1e-6m,
"口径A0.325 × 50,000,000 = 16,250,000");
// 错误:口径B → CloseNotionalValue = ClosePercent(A) × PosiNotionalValue
// 如果前端传口径A的0.325但误用 PosiNotionalValue 做乘数
decimal buggyNotional = SecondClosePercentA * RemainingAfter1st;
// 0.325 × 32,500,000 = 10,562,500 ≠ 16,250,000
Assert.AreNotEqual(SecondCloseNotional, buggyNotional,
"口径A的0.325 × 剩余本金32,500,000 = 10,562,500 ≠ 16,250,000,乘数错了");
Console.WriteLine($"正确={correctNotional},错误(用剩余)={buggyNotional}");
}
// ================================================================
// 契约3oriClosePercent = PosiNotionalValue / NotionalValue(不能硬编码为1
// 如果有人把 oriClosePercent 改成 1,此测试会红
// ================================================================
[TestMethod]
public void CPC_003_最多可平比例必须为剩余除以期初_不能硬编码为1()
{
// 正确:oriClosePercent = PosiNotionalValue / NotionalValue
decimal correctOri = RemainingAfter1st / OriginalNotional;
SwapDealTestFactory.AssertDecimalEqual(0.65m, correctOri, 1e-10m,
"oriClosePercent = 32,500,000 / 50,000,000 = 0.65");
// 错误:硬编码为 1(c9071a4e 的错误)
decimal buggyOri = 1m;
Assert.AreNotEqual(correctOri, buggyOri,
"多次部分平仓后 oriClosePercent 必须小于 1,硬编码 1 会允许平超过剩余持仓");
// 首次平仓时 oriClosePercent 才等于 1remaining == original
decimal firstTimeOri = OriginalNotional / OriginalNotional;
SwapDealTestFactory.AssertDecimalEqual(1m, firstTimeOri, 1e-10m,
"首次平仓时 oriClosePercent = 1remaining == original");
Console.WriteLine($"多次部分平仓后:oriClosePercent={correctOri}(≠1),首次:{firstTimeOri}=1");
}
// ================================================================
// 契约4CloseQty 必须经过 A→B 转换,不能直接 PositionQty × ClosePercent(A)
// 如果有人删除 calcCloseQtyByOriginalPercent 调用改为直接乘,此测试会红
// ================================================================
[TestMethod]
public void CPC_004_平仓数量必须经过口径A到B转换_不能直接乘()
{
// 场景:期初数量 32,500,000=剩余数量),oriClosePercent=0.65
// 用户输入 ClosePercent(A) = 0.325(想平剩余的 50%
decimal positionQty = RemainingAfter1st; // 32,500,000
decimal oriClosePercent = RemainingAfter1st / OriginalNotional; // 0.65
// 正确:CloseQty = PositionQty × (ClosePercent(A) / oriClosePercent)
// = 32,500,000 × (0.325 / 0.65) = 32,500,000 × 0.5 = 16,250,000
decimal correctQty = positionQty * (SecondClosePercentA / oriClosePercent);
SwapDealTestFactory.AssertDecimalEqual(16_250_000m, correctQty, 1e-6m,
"正确:32,500,000 × (0.325/0.65) = 16,250,000");
// 错误:CloseQty = PositionQty × ClosePercent(A)(直接乘,不做转换)
// = 32,500,000 × 0.325 = 10,562,500 ❌
decimal buggyQty = positionQty * SecondClosePercentA;
Assert.AreNotEqual(correctQty, buggyQty,
"直接乘会得到 10,562,500 而非 16,250,000,数量算少 35%");
// JS 浮点精度守卫:32500000×(0.5/0.65) 可能 = 24999999.999999996
// SwapCalc.calcCloseQtyByOriginalPercent 用 roundHalfAwayFromZero 修复
decimal jsFloatTrap = (decimal)((double)positionQty * ((double)SecondClosePercentA / (double)oriClosePercent));
Console.WriteLine($"正确={correctQty},错误(直接乘)={buggyQty}JS浮点陷阱={jsFloatTrap}");
}
// ================================================================
// 契约5:后端 A→B→A 往返转换必须还原(多次部分平仓场景)
// 守护 SwapUnwind/ApplySwapTrade 入口的 ToRemainingClosePercent + SaveSwapDealInternal 的 ToOriginalClosePercent
// ================================================================
[TestMethod]
public void CPC_005_多次部分平仓_A到B到A往返转换必须还原原值()
{
// 第二次部分平仓:前端传 ClosePercent(A) = 0.325
decimal closePercentA = SecondClosePercentA;
// 后端入口:A → B
decimal closePercentB = SwapDealService.ToRemainingClosePercent(
closePercentA, OriginalNotional, RemainingAfter1st);
SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentB, closePercentB, 1e-10m,
"A→B0.325 × 50,000,000 / 32,500,000 = 0.50");
// 后端落库:B → A 还原
decimal restoredA = SwapDealService.ToOriginalClosePercent(
closePercentB, OriginalNotional, RemainingAfter1st);
SwapDealTestFactory.AssertDecimalEqual(closePercentA, restoredA, 1e-10m,
"B→A 还原:0.50 × 32,500,000 / 50,000,000 = 0.325(必须等于原始A");
Console.WriteLine($"A={closePercentA} → B={closePercentB} → A'={restoredA} ✅ 往返还原");
}
// ================================================================
// 契约6:后端 InitUnwind 默认 ClosePercent = PosiNotionalValue / NotionalValue(口径A
// 不能硬编码为 1(c9071a4e 前端硬编码1的错误在后端等价于此)
// ================================================================
[TestMethod]
public void CPC_006_InitUnwind默认值必须为剩余除以期初_不能硬编码为1()
{
// 多次部分平仓后:期初 50M,剩余 32.5M
decimal defaultValue = SwapDealService.CalcDefaultInitClosePercent(
OriginalNotional, RemainingAfter1st);
// 正确:0.65(占期初的"平剩余全部"比例)
SwapDealTestFactory.AssertDecimalEqual(0.65m, defaultValue, 1e-10m,
"CalcDefaultInitClosePercent(50M, 32.5M) = 0.65(口径A");
// 不能是 1(硬编码错误)
Assert.AreNotEqual(1m, defaultValue,
"多次部分平仓后默认值不能为1,否则意味着'平掉原始全部'而非'平剩余全部'");
Console.WriteLine($"InitUnwind 默认 ClosePercent(A) = {defaultValue}(≠1)✅");
}
// ================================================================
// 契约7SwapUnwind 第二次部分平仓必须正确做 A→B 转换
// 这是"装配测试"——验证后端入口确实执行了转换,而不只是函数本身正确
// ================================================================
[TestMethod]
public void CPC_007_SwapUnwind第二次部分平仓_入口必须将ClosePercent从A转为B()
{
// 模拟 GLMS-20260701-0006 第二次部分平仓
var td = new trade
{
id = 2001,
TradeNumber = "CPC-TEST-007",
TradeType = "收益互换",
TradeStatus = "确认成交",
ValidState = "Valid",
StockEqvNotional = (double)RemainingAfter1st, // 32,500,000
OriginalStockEqvNotional = (double)OriginalNotional, // 50,000,000
Notional = (double)RemainingAfter1st,
TradeAmount = (double)RemainingAfter1st
};
var service = new TestableSwapDealService(td);
// 前端传 ClosePercent = 0.325(口径A,占期初)
var unwindData = new UnwindData
{
SwapTradeId = td.id,
SwapRealizedPnL = 1000m,
SwapCloseAmount = 1000m,
CloseMethod = (int)CloseMethodEnum.部分平仓,
ClosePercent = SecondClosePercentA, // 0.325(口径A
CloseQty = 16_250_000m,
CloseNotionalValue = SecondCloseNotional, // 16,250,000
PositionQty = RemainingAfter1st, // 32,500,000
NotionalValue = OriginalNotional, // 50,000,000(期初)
PosiNotionalValue = RemainingAfter1st, // 32,500,000(剩余)
ValueDate = new DateTime(2026, 7, 14),
UnwindDate = new DateTime(2026, 7, 15),
StartDate = new DateTime(2026, 7, 1)
};
service.SwapUnwind(unwindData);
// 验证 SwapUnwind 被调用
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "SwapUnwind 应调用 SaveSwapDeal");
// 验证传给 SaveSwapDeal 的 ClosePercent 已转为口径B
var savedData = service.SaveSwapDealCalls[0].data;
decimal expectedB = SwapDealService.ToRemainingClosePercent(
SecondClosePercentA, OriginalNotional, RemainingAfter1st);
SwapDealTestFactory.AssertDecimalEqual(expectedB, savedData.ClosePercent, 1e-10m,
"SwapUnwind 应将 ClosePercent 从口径A(0.325)转为口径B(0.50)");
// 口径B 不等于口径A(验证转换确实发生了)
Assert.AreNotEqual(SecondClosePercentA, savedData.ClosePercent,
"口径B(0.50) 不应等于口径A(0.325),否则说明转换缺失");
Console.WriteLine($"SwapUnwind: 输入A={SecondClosePercentA} → 输出B={savedData.ClosePercent} ✅");
}
// ================================================================
// 契约8:完整多步场景——3次平仓合计应等于期初本金
// 首次35%(A) → 第二次平剩余50%(A=0.325) → 第三次全平剩余(A=0.325)
// 合计 CloseNotionalValue = 17.5M + 16.25M + 16.25M = 50M = 原始本金
// ================================================================
[TestMethod]
public void CPC_008_三次部分平仓合计本金必须等于期初名义本金()
{
decimal remaining = OriginalNotional; // 50,000,000
decimal totalClosed = 0m;
// Step1: 平 35%(首次,remaining == original, A = B = 0.35
decimal step1A = 0.35m;
decimal step1Notional = step1A * OriginalNotional; // 17,500,000
totalClosed += step1Notional;
remaining -= step1Notional; // 32,500,000
// Step2: 平剩余的 50% → A = 16,250,000 / 50,000,000 = 0.325
decimal step2Notional = 16_250_000m;
decimal step2A = step2Notional / OriginalNotional; // 0.325
// 后端 A→B 转换
decimal step2B = SwapDealService.ToRemainingClosePercent(
step2A, OriginalNotional, remaining);
SwapDealTestFactory.AssertDecimalEqual(0.50m, step2B, 1e-10m,
"Step2: A=0.325 → B=0.50(平剩余50%");
totalClosed += step2Notional;
remaining -= step2Notional; // 16,250,000
// Step3: 全平剩余 → A = 16,250,000 / 50,000,000 = 0.325
decimal step3Notional = remaining;
decimal step3A = step3Notional / OriginalNotional; // 0.325
decimal step3B = SwapDealService.ToRemainingClosePercent(
step3A, OriginalNotional, remaining);
SwapDealTestFactory.AssertDecimalEqual(1.0m, step3B, 1e-10m,
"Step3: A=0.325 → B=1.0(全平剩余)");
totalClosed += step3Notional;
remaining -= step3Notional; // 0
// 守恒:合计 = 期初
SwapDealTestFactory.AssertDecimalEqual(OriginalNotional, totalClosed, 1e-6m,
"三次平仓合计必须 = 期初名义本金 50,000,000");
SwapDealTestFactory.AssertDecimalEqual(0m, remaining, 1e-6m,
"三次平仓后剩余必须 = 0");
Console.WriteLine($"Step1: A=0.35, Notional=17,500,000");
Console.WriteLine($"Step2: A=0.325→B=0.50, Notional=16,250,000");
Console.WriteLine($"Step3: A=0.325→B=1.00, Notional=16,250,000");
Console.WriteLine($"合计={totalClosed} = 期初{OriginalNotional} ✅");
}
// ================================================================
// 契约9GetUnwindInterestList 必须接收 notionalValue 和 posiNotionalValue
// 前端 getInterestList 传这两个参数给后端做 A→B 转换
// 如果前端删掉这两个参数,后端 ToRemainingClosePercent 在 posiNotionalValue=0 时会跳过转换
// ================================================================
[TestMethod]
public void CPC_009_ToRemainingClosePercent_PosiNotionalValue为零时跳过转换_前端必须传值()
{
// 模拟前端不传 notionalValue/posiNotionalValue(默认0
decimal result = SwapDealService.ToRemainingClosePercent(
SecondClosePercentA, notionalValue: 0, posiNotionalValue: 0);
// posiNotionalValue <= 0 时直接返回原值(不转换)
SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentA, result, 1e-10m,
"posiNotionalValue=0 时不做转换——所以前端必须传 notionalValue/posiNotionalValue");
// 正确:前端传值后转换正常
decimal resultWithValues = SwapDealService.ToRemainingClosePercent(
SecondClosePercentA, OriginalNotional, RemainingAfter1st);
SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentB, resultWithValues, 1e-10m,
"前端传值后:A=0.325 → B=0.50 ✅");
// 两者必须不同
Assert.AreNotEqual(result, resultWithValues,
"传 vs 不传 notionalValue 结果不同——前端必须传,否则利息计算用错口径");
Console.WriteLine($"不传值(默认0){result}(未转换,错误地用A算利息)");
Console.WriteLine($"传值:{resultWithValues}(正确转换为B");
}
// ================================================================
// 契约10:首次平仓 A==B(退化场景,不应误报)
// 首次平仓时 NotionalValue == PosiNotionalValueA=B,转换系数=1
// 这是既有测试全绿的原因——必须用非退化场景才能捕获回归
// ================================================================
[TestMethod]
public void CPC_010_首次平仓A等于B_退化场景_不能作为唯一测试()
{
decimal closePercentA = 0.35m;
decimal firstRemaining = OriginalNotional; // 首次 remaining == original
decimal convertedB = SwapDealService.ToRemainingClosePercent(
closePercentA, OriginalNotional, firstRemaining);
// 首次平仓:A == B(转换系数 = 1)
SwapDealTestFactory.AssertDecimalEqual(closePercentA, convertedB, 1e-10m,
"首次平仓 remaining==original → A==B==0.35(退化场景)");
// 退化场景下即使不做转换结果也一样——这就是既有测试全绿的原因
decimal noConversion = closePercentA;
Assert.AreEqual(noConversion, convertedB,
"退化场景:做不做转换结果一样 → 无法发现'转换缺失'的bug");
Console.WriteLine($"⚠ 退化场景:A={closePercentA} == B={convertedB}(首次平仓,无法暴露双重转换bug)");
Console.WriteLine($"✅ 非退化场景见 CPC_005/007A=0.325 ≠ B=0.50(多次部分平仓后才能暴露)");
}
}
}