diff --git a/UnitTestProject/Modules/EodModule/EodPriceUnderlyingIdGuardTest.cs b/UnitTestProject/Modules/EodModule/EodPriceUnderlyingIdGuardTest.cs new file mode 100644 index 00000000..4427770b --- /dev/null +++ b/UnitTestProject/Modules/EodModule/EodPriceUnderlyingIdGuardTest.cs @@ -0,0 +1,88 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using YLErp.DBModels; + +namespace YLErp.Modules.EodModule +{ + /// + /// FR007 错行根因的校正决策单测(GLMS-20260701)。 + /// 对应最近提交的 bugfix:eod_commodity_future_price 入库前以 UnderlyingCode(=FutureContractId) 为准 + /// 重派生 UnderlyingId,防止 UnderlyingId 与 FutureContractId 失同步导致"网页能查到、EOD 结算查不到"。 + /// 这里只测纯函数 ResolveUnderlyingIdForCode,不依赖数据库。 + /// + /// 生产事故还原:FR007 价格行的 FutureContractId='FR007',但 UnderlyingId 被错写成 + /// 511160.SH 的 2173889 / 159111.SZ 的 2173890,正确应为 FR007 的 2170838。 + /// 网页端按 UnderlyingId(int) JOIN underlying_manager 把 FR007 行误挂到 511160.SH; + /// 而 EOD 结算按 UnderlyingCode(string) JOIN 查不到,报"结算价格缺失"。 + /// + [TestClass] + public class EodPriceUnderlyingIdGuardTest + { + private const int Fr007CorrectId = 2170838; + private const int Id511160 = 2173889; // 511160.SH 的 id(被错写) + private const int Id159111 = 2173890; // 159111.SZ 的 id(被错写) + + [TestMethod] + public void 空UnderlyingCode_维持原值() + { + Assert.AreEqual(123, EodPriceService.ResolveUnderlyingIdForCode("", 123, null)); + Assert.AreEqual(123, EodPriceService.ResolveUnderlyingIdForCode(null, 123, 999)); + Assert.AreEqual(123, EodPriceService.ResolveUnderlyingIdForCode(" ", 123, 999)); + } + + [TestMethod] + public void 标的不存在_维持原值() + { + // resolvedId=null 表示 underlying_manager 无此代码,无法校正 + Assert.AreEqual(Id511160, + EodPriceService.ResolveUnderlyingIdForCode("FR007", Id511160, null)); + } + + [TestMethod] + public void 已一致_维持原值() + { + Assert.AreEqual(Fr007CorrectId, + EodPriceService.ResolveUnderlyingIdForCode("FR007", Fr007CorrectId, Fr007CorrectId)); + } + + [TestMethod] + public void 不一致_校正为正确id_FR007生产错行_511160() + { + // 生产事故:FR007 行 UnderlyingId=2173889(511160.SH) → 应校正为 2170838 + Assert.AreEqual(Fr007CorrectId, + EodPriceService.ResolveUnderlyingIdForCode("FR007", Id511160, Fr007CorrectId)); + } + + [TestMethod] + public void 不一致_校正为正确id_FR007生产错行_159111() + { + // 生产事故:另两条错行 UnderlyingId=2173890(159111.SZ) → 应校正为 2170838 + Assert.AreEqual(Fr007CorrectId, + EodPriceService.ResolveUnderlyingIdForCode("FR007", Id159111, Fr007CorrectId)); + } + + [TestMethod] + public void 行对象_端到端校正_FR007() + { + var row = new eod_commodity_future_price + { + UnderlyingCode = "FR007", + UnderlyingId = Id511160 + }; + // 模拟 db.underlying_manager 解析到的正确 id + int resolved = Fr007CorrectId; + int? before = row.UnderlyingId; + row.UnderlyingId = EodPriceService.ResolveUnderlyingIdForCode(row.UnderlyingCode, row.UnderlyingId ?? 0, resolved); + + Assert.AreNotEqual(before, row.UnderlyingId); + Assert.AreEqual((int?)Fr007CorrectId, row.UnderlyingId); + } + + [TestMethod] + public void 典型非错配_不同标的_各自正确不互相覆盖() + { + // 511160.SH 自己的行(FutureContractId='511160.SH'),UnderlyingId 已是 2173889 → 不动 + Assert.AreEqual(Id511160, + EodPriceService.ResolveUnderlyingIdForCode("511160.SH", Id511160, Id511160)); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/ApplySwapTradeClosePercentBugTest.cs b/UnitTestProject/Modules/SwapModule/ApplySwapTradeClosePercentBugTest.cs new file mode 100644 index 00000000..2d486df2 --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ApplySwapTradeClosePercentBugTest.cs @@ -0,0 +1,180 @@ +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 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 + /// + [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}"); + } + } +} diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs index ad2b5f12..4b41b962 100644 --- a/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs +++ b/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs @@ -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(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 } } diff --git a/YLErpDAL/Modules/EodModule/EodPriceService.cs b/YLErpDAL/Modules/EodModule/EodPriceService.cs index ce2ced60..449a4c75 100644 --- a/YLErpDAL/Modules/EodModule/EodPriceService.cs +++ b/YLErpDAL/Modules/EodModule/EodPriceService.cs @@ -29,6 +29,36 @@ namespace YLErp.Modules.EodModule return (start, end); } + /// + /// 校正 eod_commodity_future_price 行的 UnderlyingId,使其与 UnderlyingCode(=FutureContractId) 一致。 + /// 背景:网页日终价格列表按 UnderlyingId(int) JOIN underlying_manager,而 EOD 结算(EodPriceProvider.Initialize) + /// 按 UnderlyingCode(string) JOIN。两列一旦失同步(典型如 FR007 的价格行 UnderlyingId 被错写成 511160.SH 的 id), + /// 会出现"网页能查到、结算却查不到"的错价缺失,进而 EodCheckSettlePrice 报"结算价格缺失"。 + /// 这里以 UnderlyingCode 为准重新派生 UnderlyingId——该列才是上传/结算使用的自然键(FutureContractId), + /// 在入库前强制两列一致,既阻止产生新的错行,又通过告警日志把失同步暴露给运维追查上游写入来源。 + /// + /// + /// 纯函数:根据 UnderlyingCode 校正决策。给定当前 UnderlyingId 与从 underlying_manager 解析到的正确 id, + /// 返回应使用的 UnderlyingId。UnderlyingCode 为空或库中无对应标的(resolvedId=null)时维持原值, + /// 已一致时也维持原值,仅在不一致时返回正确 id。抽成纯函数便于无数据库单测(覆盖 GLMS-20260701 FR007 错行根因)。 + /// + public static int ResolveUnderlyingIdForCode(string underlyingCode, int currentId, int? resolvedId) + { + if (string.IsNullOrWhiteSpace(underlyingCode)) + { + return currentId; + } + if (resolvedId == null) + { + return currentId; + } + if (resolvedId.Value == currentId) + { + return currentId; + } + return resolvedId.Value; + } + /// /// 校正 eod_commodity_future_price 行的 UnderlyingId,使其与 UnderlyingCode(=FutureContractId) 一致。 /// 背景:网页日终价格列表按 UnderlyingId(int) JOIN underlying_manager,而 EOD 结算(EodPriceProvider.Initialize) @@ -45,18 +75,14 @@ namespace YLErp.Modules.EodModule } var um = db.underlying_manager.FirstOrDefault(u => u.UnderlyingCode == row.UnderlyingCode); - if (um == null) - { - // 标的代码在 underlying_manager 不存在:无法校正,交由既有"标的代码不存在"等校验处理。 - return; - } - - if (row.UnderlyingId != um.id) + var resolvedId = um == null ? (int?)null : um.id; + var before = row.UnderlyingId; + row.UnderlyingId = ResolveUnderlyingIdForCode(row.UnderlyingCode, row.UnderlyingId ?? 0, resolvedId); + if (row.UnderlyingId != before) { LogFactory.GetLogger("EodPrice").Info( $"eod_commodity_future_price.UnderlyingId 与 UnderlyingCode 不一致,已自动校正: " + - $"FutureContractId={row.UnderlyingCode}, 原UnderlyingId={row.UnderlyingId}, 修正为={um.id}"); - row.UnderlyingId = um.id; + $"FutureContractId={row.UnderlyingCode}, 原UnderlyingId={before}, 修正为={row.UnderlyingId}"); } } diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index bff18695..eb960cc4 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -1838,6 +1838,11 @@ namespace YLErp.Modules.SwapModule ValidateIncomeValueDate(unwindData, td); } unwindData.SwapRealizedPnL = unwindData.SwapCloseAmount; + // 前端按"占期初(original)"语义传 ClosePercent(A);后端全链路按"占剩余(remaining)"语义(B)消费。 + // 入口统一转换为 B,落库展示用的 A 由 SaveSwapDealInternal 还原。 + // 与 SwapUnwind(L1270) 保持一致——缺少此转换会导致 SaveSwapDealInternal 的 B→A 还原出错 + // (例如第二次部分平仓 50%(A) → 错误还原为 0.325 而非 0.50)。 + unwindData.ClosePercent = ToRemainingClosePercent(unwindData.ClosePercent, unwindData.NotionalValue, unwindData.PosiNotionalValue); string action = eventType == (int)SwapEventTypeEnum.互换 ? ClientCashInCashOut.系统操作_互换 : ClientCashInCashOut.系统操作_平仓费; ExecuteInTransaction(() => { diff --git a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml index 9eb4bd3b..1011c3c8 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml @@ -22,6 +22,7 @@ + }
diff --git a/YLErpWeb/fe-tests/swapCalc.test.js b/YLErpWeb/fe-tests/swapCalc.test.js index 90dfe9c5..71c4993e 100644 --- a/YLErpWeb/fe-tests/swapCalc.test.js +++ b/YLErpWeb/fe-tests/swapCalc.test.js @@ -170,6 +170,18 @@ describe('多次部分平仓:全部↔部分切换 CloseQty 不跳变(占期 test('除零保护:PositionQty=0 → ClosePercent=0', () => { expect(SwapCalc.calcOriginalClosePercentByQty(100, 0, oriClosePercent)).toBe(0); }); + + // GLMS-20260701-0006 生产 bug:第二次部分平仓 50%(占期初) 时 + // 32500000 * (0.5 / 0.65) = 24999999.999999996(JS 浮点精度偏差) + // roundHalfAwayFromZero 应正确舍入为 25000000 + test('GLMS-20260701-0006:32500000×(0.5/0.65) 应=25000000 而非 24999999.999999996', () => { + const closePercent = 0.5; // 占期初 50% + const oriClosePercent = 0.65; // 剩余/期初 = 32500000/50000000 + const positionQty = 32500000; // 剩余持仓 + const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, positionQty); + expectClose(closeQty, 25000000, '应=25000000 不受 JS 浮点偏差影响'); + expect(closeQty).not.toBe(24999999.999999996); + }); }); describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', () => { diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 3faf8edb..c7fd23ff 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -140,10 +140,10 @@ const vue = new Vue({ }, // 按"占期初口径(A)"的 ClosePercent 反算平仓数量:CloseQty = PositionQty × (ClosePercent / oriClosePercent) // 多次部分平仓后必须这样转换,否则全部↔部分切换时 ClosePercent 没变但 CloseQty 会变(不自洽) + // 使用 swapCalc.calcCloseQtyByOriginalPercent 的 roundHalfAwayFromZero 避免 JS 浮点精度偏差 + // (如 32500000*(0.5/0.65)=24999999.999999996 而非 25000000) calcCloseQtyByPercent(closePercent) { - var ori = parseFloat(this.oriClosePercent) || 0; - var ratio = ori > 0 ? parseFloat(closePercent) / ori : 0; - return otcformat.trading.notional(parseFloat(this.deal.PositionQty) * ratio); + return SwapCalc.calcCloseQtyByOriginalPercent(closePercent, this.oriClosePercent, this.deal.PositionQty); }, calcTradingFeePending() { this.floatPosition.TradingFeePending = this.floatPosition.BeforeCloseFee * parseFloat(this.deal.ClosePercent);