fix(swap): 修复多次部分平仓 ClosePercent 口径(A占期初→B占剩余)转换缺失
- SwapDealService.ApplySwapTrade 入口补 ToRemainingClosePercent(A→B) 转换,与 SwapUnwind 保持一致;缺此转换导致 SaveSwapDealInternal 的 B→A 还原出错(GLMS-20260701-0006:第二次部分平仓50%被错误还原为32.5%) - unwindSwapTrade.js 改用 swapCalc.calcCloseQtyByOriginalPercent 的 roundHalfAwayFromZero,规避 JS 浮点精度偏差(32500000*(0.5/0.65) 应为 25000000) - SwapUnwind.cshtml 引入 swapCalc.js - 新增 ApplySwapTradeClosePercentBugTest 回归测试;补充 swapCalc.test.js 精度用例与 GLMS20260701DbDiagnoseTest 诊断测试([Ignore])
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
using YLErp.DBModels;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// GLMS-20260701-0006 多次部分平仓后提前终止 Tab 序号2 平仓比例显示 32.50% 而非 50% 的回归测试
|
||||
/// ================================================================================
|
||||
/// 根因:SwapDealService.ApplySwapTrade 缺少 A→B 口径转换。
|
||||
/// - SwapUnwind 在入口处将 ClosePercent 从口径A(占期初) 转为 口径B(占剩余),SaveSwapDealInternal 落库时 B→A 还原。
|
||||
/// - ApplySwapTrade 没有做 A→B 转换,导致 SaveSwapDealInternal 的 B→A 还原出错:
|
||||
/// 0.50(A) → ToOriginalClosePercent(0.50, 50000000, 32500000) = 0.50*32500000/50000000 = 0.325 ❌
|
||||
/// - 修复后:0.50(A) → ToRemainingClosePercent → 0.769(B) → ToOriginalClosePercent → 0.50(A) ✅
|
||||
///
|
||||
/// 测试策略:
|
||||
/// 1) 纯函数测试:验证 A→B→A 往返转换的正确性
|
||||
/// 2) ApplySwapTrade 集成测试:验证 SaveSwapDeal 收到的 ClosePercent 已转为口径B
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class ApplySwapTradeClosePercentBugTest
|
||||
{
|
||||
// GLMS-20260701-0006 真实数据
|
||||
private const decimal OriginalNotional = 50_000_000m; // 期初名义本金
|
||||
private const decimal RemainingAfter1st = 32_500_000m; // 首次平35%后剩余
|
||||
private const decimal FirstClosePercent = 0.35m; // 第一次平仓比例(口径A)
|
||||
private const decimal SecondClosePercent = 0.50m; // 第二次平仓比例(口径A, 用户输入50%)
|
||||
|
||||
// ================================================================
|
||||
// 1) 纯函数:A→B→A 往返转换应还原原值
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void AC_001_口径转换_往返A到B到A应还原原值()
|
||||
{
|
||||
// 第二次部分平仓: 用户输入 50%(口径A)
|
||||
decimal closePercentA = SecondClosePercent;
|
||||
|
||||
// A → B(ApplySwapTrade/SwapUnwind 入口转换)
|
||||
decimal closePercentB = SwapDealService.ToRemainingClosePercent(
|
||||
closePercentA, OriginalNotional, RemainingAfter1st);
|
||||
|
||||
// B → A(SaveSwapDealInternal 落库还原)
|
||||
decimal closePercentA_restored = SwapDealService.ToOriginalClosePercent(
|
||||
closePercentB, OriginalNotional, RemainingAfter1st);
|
||||
|
||||
SwapDealTestFactory.AssertDecimalEqual(closePercentA, closePercentA_restored, 1e-10m,
|
||||
"A→B→A 往返转换应还原原值");
|
||||
Console.WriteLine($"A={closePercentA}, B={closePercentB}, A_restored={closePercentA_restored}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AC_002_口径转换_未修复时B到A会得到错误的0_325()
|
||||
{
|
||||
// 模拟 bug:ApplySwapTrade 未做 A→B 转换,直接把 A 传给 SaveSwapDealInternal 的 B→A 还原
|
||||
decimal closePercentA = SecondClosePercent; // 0.50
|
||||
|
||||
// bug 路径:SaveSwapDealInternal 误把 A 当 B 做还原
|
||||
decimal buggyResult = SwapDealService.ToOriginalClosePercent(
|
||||
closePercentA, OriginalNotional, RemainingAfter1st);
|
||||
|
||||
// 0.50 * 32500000 / 50000000 = 0.325
|
||||
SwapDealTestFactory.AssertDecimalEqual(0.325m, buggyResult, 1e-10m,
|
||||
"bug 路径:0.50(A) 被误当 B 做还原 → 0.325");
|
||||
Assert.AreNotEqual(SecondClosePercent, buggyResult,
|
||||
"bug 结果 0.325 不等于用户输入 0.50");
|
||||
Console.WriteLine($"Bug: 0.50(A) 误当 B → ToOriginalClosePercent → {buggyResult} (应为 0.50)");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 2) ApplySwapTrade 集成测试:验证 SaveSwapDeal 收到的是口径B
|
||||
// ================================================================
|
||||
|
||||
[TestMethod]
|
||||
public void AC_003_ApplySwapTrade_第二次部分平仓50perc_应将ClosePercent转为口径B()
|
||||
{
|
||||
// 模拟 GLMS-20260701-0006 第二次部分平仓的场景
|
||||
var td = new trade
|
||||
{
|
||||
id = 1991,
|
||||
TradeNumber = "GLMS-20260701-0006",
|
||||
TradeType = "收益互换",
|
||||
TradeStatus = "确认成交",
|
||||
ValidState = "Valid",
|
||||
StockEqvNotional = (double)RemainingAfter1st, // 32500000
|
||||
OriginalStockEqvNotional = (double)OriginalNotional, // 50000000
|
||||
Notional = (double)RemainingAfter1st,
|
||||
TradeAmount = (double)RemainingAfter1st
|
||||
};
|
||||
|
||||
var service = new TestableSwapDealService(td);
|
||||
|
||||
// 前端传入的 UnwindData(ClosePercent = 0.50, 口径A)
|
||||
var unwindData = new UnwindData
|
||||
{
|
||||
SwapTradeId = td.id,
|
||||
SwapRealizedPnL = 1000m,
|
||||
SwapCloseAmount = 1000m,
|
||||
CloseMethod = (int)CloseMethodEnum.部分平仓,
|
||||
ClosePercent = SecondClosePercent, // 0.50 (口径A, 用户输入50%)
|
||||
CloseQty = 25000000m,
|
||||
CloseNotionalValue = 25000000m, // 50% of original
|
||||
PositionQty = RemainingAfter1st, // 32500000
|
||||
NotionalValue = OriginalNotional, // 50000000 (期初)
|
||||
PosiNotionalValue = RemainingAfter1st, // 32500000 (剩余)
|
||||
ValueDate = new DateTime(2026, 7, 14),
|
||||
UnwindDate = new DateTime(2026, 7, 15),
|
||||
StartDate = new DateTime(2026, 7, 1)
|
||||
};
|
||||
|
||||
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓);
|
||||
|
||||
// 验证 SaveSwapDeal 被调用
|
||||
Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "ApplySwapTrade 应调用 SaveSwapDeal");
|
||||
|
||||
// 验证传给 SaveSwapDeal 的 ClosePercent 已转为口径B
|
||||
var savedData = service.SaveSwapDealCalls[0].data;
|
||||
decimal expectedB = SwapDealService.ToRemainingClosePercent(
|
||||
SecondClosePercent, OriginalNotional, RemainingAfter1st);
|
||||
|
||||
SwapDealTestFactory.AssertDecimalEqual(expectedB, savedData.ClosePercent, 1e-10m,
|
||||
"ApplySwapTrade 应将 ClosePercent 从口径A转为口径B");
|
||||
|
||||
// 关键验证:B 值不应等于 A 值(0.50),也不应等于 bug 值(0.325)
|
||||
Assert.AreNotEqual(SecondClosePercent, savedData.ClosePercent,
|
||||
"口径B 不应等于口径A (0.50)");
|
||||
Assert.AreNotEqual(0.325m, savedData.ClosePercent,
|
||||
"口径B 不应等于 bug 值 (0.325)");
|
||||
|
||||
Console.WriteLine($"输入: ClosePercent(A)={SecondClosePercent}");
|
||||
Console.WriteLine($"输出: ClosePercent(B)={savedData.ClosePercent}");
|
||||
Console.WriteLine($"期望: ClosePercent(B)={expectedB}");
|
||||
Console.WriteLine($"往返还原: ClosePercent(A)={SwapDealService.ToOriginalClosePercent(savedData.ClosePercent, OriginalNotional, RemainingAfter1st)}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AC_004_ApplySwapTrade_第一次平仓35perc_口径转换正确()
|
||||
{
|
||||
// 第一次平仓:remaining = original, 所以 A = B = 0.35
|
||||
var td = new trade
|
||||
{
|
||||
id = 1991,
|
||||
TradeNumber = "GLMS-20260701-0006",
|
||||
TradeType = "收益互换",
|
||||
TradeStatus = "确认成交",
|
||||
ValidState = "Valid",
|
||||
StockEqvNotional = (double)OriginalNotional,
|
||||
OriginalStockEqvNotional = (double)OriginalNotional,
|
||||
Notional = (double)OriginalNotional,
|
||||
TradeAmount = (double)OriginalNotional
|
||||
};
|
||||
|
||||
var service = new TestableSwapDealService(td);
|
||||
|
||||
var unwindData = new UnwindData
|
||||
{
|
||||
SwapTradeId = td.id,
|
||||
SwapRealizedPnL = 1000m,
|
||||
SwapCloseAmount = 1000m,
|
||||
CloseMethod = (int)CloseMethodEnum.部分平仓,
|
||||
ClosePercent = FirstClosePercent, // 0.35 (口径A)
|
||||
CloseQty = 17500000m,
|
||||
CloseNotionalValue = 17500000m,
|
||||
PositionQty = OriginalNotional,
|
||||
NotionalValue = OriginalNotional,
|
||||
PosiNotionalValue = OriginalNotional, // 首次平仓 remaining == original
|
||||
ValueDate = new DateTime(2026, 7, 6),
|
||||
UnwindDate = new DateTime(2026, 7, 7),
|
||||
StartDate = new DateTime(2026, 7, 1)
|
||||
};
|
||||
|
||||
service.ApplySwapTrade(unwindData, (int)SwapEventTypeEnum.平仓);
|
||||
|
||||
var savedData = service.SaveSwapDealCalls[0].data;
|
||||
// 首次平仓 remaining == original → A == B == 0.35
|
||||
SwapDealTestFactory.AssertDecimalEqual(FirstClosePercent, savedData.ClosePercent, 1e-10m,
|
||||
"首次平仓 remaining==original → 口径A==口径B==0.35");
|
||||
Console.WriteLine($"首次平仓: ClosePercent(A=B)={savedData.ClosePercent}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,5 +258,104 @@ namespace YLErp.Modules.SwapModule
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 3) 诊断 GLMS-20260701-0006:部分平仓比例显示 32.50% 而非 50%
|
||||
|
||||
[TestMethod]
|
||||
[TestCategory("DbDiagnose")]
|
||||
public void Diagnose_0006_UnwindPercentRate_Display()
|
||||
{
|
||||
const string tradeNumber = "GLMS-20260701-0006";
|
||||
YLContext db;
|
||||
try { db = DbContextFactory.GetYLDbContext(); }
|
||||
catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; }
|
||||
|
||||
var td = db.trade.FirstOrDefault(t => t.TradeNumber == tradeNumber);
|
||||
if (td == null) { Assert.Inconclusive($"测试库无 {tradeNumber}"); return; }
|
||||
|
||||
Console.WriteLine($"===== 交易 {tradeNumber} (id={td.id}) =====");
|
||||
Console.WriteLine($" TradeType: {td.TradeType}");
|
||||
Console.WriteLine($" TradeStatus: {td.TradeStatus}");
|
||||
Console.WriteLine($" StockEqvNotional (剩余): {td.StockEqvNotional}");
|
||||
Console.WriteLine($" OriginalStockEqvNotional (期初): {td.OriginalStockEqvNotional}");
|
||||
Console.WriteLine($" Notional: {td.Notional}");
|
||||
Console.WriteLine($" OriginalNotional: {td.OriginalNotional}");
|
||||
Console.WriteLine($" TradeAmount: {td.TradeAmount}");
|
||||
Console.WriteLine($" HasPartialUnWind: {td.HasPartialUnWind}");
|
||||
Console.WriteLine($" 剩余比例 = StockEqvNotional/Original = {td.StockEqvNotional / td.OriginalStockEqvNotional}");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine($"===== trade_cash 记录 =====");
|
||||
var tradeCashList = db.trade_cash
|
||||
.Where(t => t.TradeId == td.id && !t.IsDeleted &&
|
||||
(t.Action == "系统操作-平仓费" || t.Action == "系统操作-行权费"))
|
||||
.OrderBy(t => t.ValueDate).ThenBy(t => t.id)
|
||||
.ToList();
|
||||
|
||||
foreach (var tc in tradeCashList)
|
||||
{
|
||||
Console.WriteLine($" [id={tc.id}] ValueDate={tc.ValueDate:yyyy-MM-dd} Action={tc.Action}");
|
||||
Console.WriteLine($" UnwindType: {tc.UnwindType}");
|
||||
Console.WriteLine($" UnwindPercentRate: {tc.UnwindPercentRate} (=> {tc.UnwindPercentRate * 100}%)");
|
||||
Console.WriteLine($" UnwindStockEqvNotional: {tc.UnwindStockEqvNotional}");
|
||||
Console.WriteLine($" UnwindNotional: {tc.UnwindNotional}");
|
||||
Console.WriteLine($" UnwindTradeAmount: {tc.UnwindTradeAmount}");
|
||||
Console.WriteLine($" UnwindMethod: {tc.UnwindMethod}");
|
||||
Console.WriteLine($" ValidState: {tc.ValidState}");
|
||||
Console.WriteLine($" IsLastAction: {tc.IsLastAction}");
|
||||
Console.WriteLine($" ExerciseWay: {tc.ExerciseWay}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine($"===== swap_event 记录 =====");
|
||||
var swapEvents = db.swap_event
|
||||
.Where(e => e.SwapTradeId == td.id && !e.Invalid)
|
||||
.OrderBy(e => e.ValueDate).ThenBy(e => e.id)
|
||||
.ToList();
|
||||
|
||||
foreach (var se in swapEvents)
|
||||
{
|
||||
Console.WriteLine($" [id={se.id}] ValueDate={se.ValueDate:yyyy-MM-dd} EventType={se.EventType}");
|
||||
Console.WriteLine($" EventReason: {se.EventReason}");
|
||||
Console.WriteLine($" ClientCashId: {se.ClientCashId}");
|
||||
if (!string.IsNullOrEmpty(se.EventData))
|
||||
{
|
||||
try
|
||||
{
|
||||
var ud = JsonConvert.DeserializeObject<JObject>(se.EventData);
|
||||
Console.WriteLine($" EventData.ClosePercent: {ud["ClosePercent"]}");
|
||||
Console.WriteLine($" EventData.CloseNotionalValue: {ud["CloseNotionalValue"]}");
|
||||
Console.WriteLine($" EventData.CloseQty: {ud["CloseQty"]}");
|
||||
Console.WriteLine($" EventData.NotionalValue: {ud["NotionalValue"]}");
|
||||
Console.WriteLine($" EventData.PosiNotionalValue: {ud["PosiNotionalValue"]}");
|
||||
Console.WriteLine($" EventData.PositionQty: {ud["PositionQty"]}");
|
||||
Console.WriteLine($" EventData.CloseMethod: {ud["CloseMethod"]}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" EventData parse error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// 查询 swap_flow_event 记录
|
||||
Console.WriteLine($"===== swap_flow_event 记录 =====");
|
||||
var flowEvents = db.swap_flow_event
|
||||
.Where(f => f.SwapTradeId == td.id)
|
||||
.OrderBy(f => f.EventDate).ThenBy(f => f.id)
|
||||
.ToList();
|
||||
|
||||
foreach (var fe in flowEvents)
|
||||
{
|
||||
Console.WriteLine($" [id={fe.id}] EventDate={fe.EventDate:yyyy-MM-dd} EventType={fe.EventType}");
|
||||
Console.WriteLine($" PositionId: {fe.PositionId}");
|
||||
Console.WriteLine($" Quantity: {fe.Quantity}");
|
||||
Console.WriteLine($" PositionQty: {fe.PositionQty}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user