From 62c26bd7f573afa737bd9955c332d61cdadab042 Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 16 Jul 2026 20:57:08 +0800 Subject: [PATCH] =?UTF-8?q?fix=20=E9=83=A8=E5=88=86=E5=B9=B3=E4=BB=93=20?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=88=87=E6=8D=A2=20=E4=BC=9A=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E4=B8=8D=E4=B8=80=E8=87=B4=EF=BC=8C=E6=B5=AE=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E4=BC=9A=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/fe-tests/swapCalc.test.js | 60 +++++++++++++++++++ .../wwwroot/Scripts/app/swaptrade/swapCalc.js | 22 +++++++ .../Scripts/app/swaptrade/unwindSwapTrade.js | 29 ++++++--- 3 files changed, 104 insertions(+), 7 deletions(-) diff --git a/YLErpWeb/fe-tests/swapCalc.test.js b/YLErpWeb/fe-tests/swapCalc.test.js index 6e2c059f..435bc4a8 100644 --- a/YLErpWeb/fe-tests/swapCalc.test.js +++ b/YLErpWeb/fe-tests/swapCalc.test.js @@ -101,6 +101,66 @@ describe('多次部分平仓:比例↔名义本金换算以 PosiNotionalValue }); }); +// ============================================================================ +// 多次部分平仓:全部↔部分切换时 CloseQty 不应随 ClosePercent 口径变化而跳变 +// 守卫 unwindSwapTrade.js changeCloseMethod/changeClosePercent/changeCloseQty/changeCloseNotionalValue +// 旧 bug:ClosePercent 是占期初口径(A),但 CloseQty=PositionQty*ClosePercent 错当占剩余(B)用 +// 全部(ClosePercent=0.7,CloseQty=720000) → 部分(ClosePercent没变, CloseQty=504000) ❌ 跳变 +// 修复:CloseQty = PositionQty × (ClosePercent / oriClosePercent) +// ============================================================================ +describe('多次部分平仓:全部↔部分切换 CloseQty 不跳变(占期初口径自洽)', () => { + // 场景基于 GLMS-20260701-0013: + // 期初 NotionalValue=980000,已平两次剩 PosiNotionalValue=686000, PositionQty=720000 + // oriClosePercent = 686000/980000 = 0.7 + const NotionalValue = 980000; + const PosiNotionalValue = 686000; + const PositionQty = 720000; + const oriClosePercent = PosiNotionalValue / NotionalValue; // 0.7 + + test('全部平仓 ClosePercent=0.7 → CloseQty=720000(剩余全部)', () => { + const closePercent = oriClosePercent; // 全部平仓 = oriClosePercent + const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, PositionQty); + expectClose(closeQty, 720000, '全部平仓应平剩余全部数量'); + }); + + test('全部→部分切换(ClosePercent 没变=0.7):CloseQty 应保持 720000 不跳变', () => { + // 旧 bug:CloseQty = 720000 * 0.7 = 504000(错误跳变) + // 修复:CloseQty = 720000 * (0.7/0.7) = 720000(不变) + const closePercent = oriClosePercent; + const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, PositionQty); + const buggyCloseQty = Math.round(PositionQty * closePercent * 100) / 100; // 旧 bug 公式 + expectClose(closeQty, 720000, '修复后应不变'); + expect(Math.abs(buggyCloseQty - 504000)).toBeLessThanOrEqual(TOL, '旧 bug 会跳变到 504000'); + expect(Math.abs(closeQty - buggyCloseQty)).toBeGreaterThan(TOL, '修复与旧 bug 必须有差异'); + }); + + test('改比例 0.5(占期初)→ CloseQty=720000×(0.5/0.7)=514285.71', () => { + const closePercent = 0.5; + const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, PositionQty); + expectClose(closeQty, 514285.71, '占期初 50% 对应占剩余 71.43%,×720000=514285.71'); + }); + + test('逆运算:CloseQty=514285.71 → ClosePercent=0.5(占期初)', () => { + const closeQty = 514285.71; + const closePercent = SwapCalc.calcOriginalClosePercentByQty(closeQty, PositionQty, oriClosePercent); + expectClose(closePercent, 0.5, '逆运算应还原为占期初 0.5'); + }); + + test('逆运算自洽:CloseQty=720000(=PositionQty)→ ClosePercent=0.7(=oriClosePercent)', () => { + const closeQty = PositionQty; // 720000 + const closePercent = SwapCalc.calcOriginalClosePercentByQty(closeQty, PositionQty, oriClosePercent); + expectClose(closePercent, oriClosePercent, '平全部剩余 → ClosePercent=oriClosePercent'); + }); + + test('除零保护:oriClosePercent=0(已全平完)→ CloseQty=0', () => { + expect(SwapCalc.calcCloseQtyByOriginalPercent(0.5, 0, 720000)).toBe(0); + }); + + test('除零保护:PositionQty=0 → ClosePercent=0', () => { + expect(SwapCalc.calcOriginalClosePercentByQty(100, 0, oriClosePercent)).toBe(0); + }); +}); + describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', () => { // FC_001 平仓-债券多头-默认 test('FC_001 平仓 债券多头 默认', () => { diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js index e41565df..65a47daa 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -78,6 +78,26 @@ return roundHalfAwayFromZero(Number(closeNotionalValue) / Number(posiNotionalValue), 6); } + // 平仓数量(占期初口径 A):CloseQty = PositionQty × (closePercent / oriClosePercent) + // closePercent 是"占期初名义本金比例"(A),需先除以 oriClosePercent(=剩余/期初) 转成"占剩余比例"(B), + // 再乘以剩余持仓数量 PositionQty。 + // 多次部分平仓后必须这样转换,否则全部↔部分切换时 ClosePercent 没变但 CloseQty 会变(不自洽)。 + // 除零保护:oriClosePercent=0(剩余为0,已全部平完)时返回 0。 + function calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, positionQty) { + var ori = Number(oriClosePercent); + if (ori === 0) return 0; + return roundHalfAwayFromZero(Number(positionQty) * (Number(closePercent) / ori), 2); + } + + // 平仓比例(占期初口径 A)= (CloseQty / PositionQty) × oriClosePercent + // CloseQty/PositionQty 得到"占剩余比例"(B),乘以 oriClosePercent(=剩余/期初) 转成"占期初比例"(A)。 + // 除零保护:PositionQty=0 时返回 0。 + function calcOriginalClosePercentByQty(closeQty, positionQty, oriClosePercent) { + var qty = Number(positionQty); + if (qty === 0) return 0; + return roundHalfAwayFromZero((Number(closeQty) / qty) * Number(oriClosePercent), 6); + } + // 盯市平仓盈亏(unwind):CloseQty × (期末全价×scale − 期初全价) × floatRatio × longRatio // 对齐 FrontendCalcReference.CalcUnwind:先 ×10000 取整再 ÷10000,最后 toFixed(2) // 干净输入下等价于直接 round(.., 2) @@ -179,6 +199,8 @@ calcStockEqvNotional: calcStockEqvNotional, calcCloseNotionalByRemaining: calcCloseNotionalByRemaining, calcClosePercentByRemaining: calcClosePercentByRemaining, + calcCloseQtyByOriginalPercent: calcCloseQtyByOriginalPercent, + calcOriginalClosePercentByQty: calcOriginalClosePercentByQty, calcMarkClosePnl: calcMarkClosePnl, calcUnwind: calcUnwind, calcIncome: calcIncome diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 03f90bc7..3faf8edb 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -131,12 +131,20 @@ const vue = new Vue({ this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.PosiNotionalValue)); this.deal.CloseQty = this.deal.PositionQty; } else { - this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent)); + // ClosePercent 是占期初口径(A),需除以 oriClosePercent 转占剩余(B) 再乘剩余数量 + this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent); } this.calcTradingFeePending(); this.getInterestList(); this.calcFloatClosePnl(); }, + // 按"占期初口径(A)"的 ClosePercent 反算平仓数量:CloseQty = PositionQty × (ClosePercent / oriClosePercent) + // 多次部分平仓后必须这样转换,否则全部↔部分切换时 ClosePercent 没变但 CloseQty 会变(不自洽) + 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); + }, calcTradingFeePending() { this.floatPosition.TradingFeePending = this.floatPosition.BeforeCloseFee * parseFloat(this.deal.ClosePercent); }, @@ -145,7 +153,9 @@ const vue = new Vue({ main.message("平仓数量不能超过持仓数量"); return; } - this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseQty) / parseFloat(this.deal.PositionQty)); + // CloseQty/PositionQty 得占剩余(B),× oriClosePercent 转回占期初(A) + var ori = parseFloat(this.oriClosePercent) || 0; + this.deal.ClosePercent = otcformat.fixed6((parseFloat(this.deal.CloseQty) / parseFloat(this.deal.PositionQty)) * ori); if (parseFloat(this.deal.CloseQty) == parseFloat(this.deal.PositionQty)) { this.deal.CloseMethod = 1; } else { @@ -163,13 +173,13 @@ const vue = new Vue({ this.deal.ClosePercent = this.oriClosePercent; return; } - this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent)); + this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent); // 占期初口径:平仓名义本金 = 平仓比例 × 期初名义本金(NotionalValue) this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue)); - if (parseFloat(this.deal.CloseNotionalValue) == parseFloat(this.deal.PosiNotionalValue)) { - this.floatPosition.CloseMethod = 1; + if (parseFloat(this.deal.ClosePercent) == parseFloat(this.oriClosePercent)) { + this.deal.CloseMethod = 1; } else { - this.floatPosition.CloseMethod = 2; + this.deal.CloseMethod = 2; } this.calcTradingFeePending(); this.getInterestList(); @@ -183,7 +193,12 @@ const vue = new Vue({ } // 占期初口径:平仓比例 = 平仓名义本金 / 期初名义本金(NotionalValue) this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseNotionalValue) / parseFloat(this.deal.NotionalValue)); - this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent)); + this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent); + if (parseFloat(this.deal.ClosePercent) == parseFloat(this.oriClosePercent)) { + this.deal.CloseMethod = 1; + } else { + this.deal.CloseMethod = 2; + } this.calcTradingFeePending(); this.getInterestList(); this.calcFloatClosePnl();