From e8d0a764422d0b8e39707169f3719e80a8a972f7 Mon Sep 17 00:00:00 2001 From: hjhan Date: Fri, 21 Aug 2026 07:17:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E5=B9=B3=E4=BB=93=E9=A1=B5?= =?UTF-8?q?=E6=94=B6=E4=BB=98=E6=96=B9=E5=90=91=E7=AC=A6=E5=8F=B7=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E5=8C=96unwindLegSign.js+4=E4=BE=8B=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E2=80=94=E2=80=94=E8=A3=B8=E4=B8=89=E5=85=83(x=3D=3D1=3F=C2=B1?= =?UTF-8?q?1:=E2=88=931)=E6=8A=BD=E4=B8=BA=E5=91=BD=E5=90=8D=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E5=B9=B6=E5=86=BB=E7=BB=93=E4=B8=9A=E5=8A=A1=E4=BA=8B?= =?UTF-8?q?=E5=AE=9E:=E5=88=A9=E6=81=AF=E7=9B=88=E4=BA=8F=E4=B8=8E?= =?UTF-8?q?=E4=BF=9D=E8=AF=81=E9=87=91=E8=BF=94=E8=BF=98=E6=9C=AC=E9=87=91?= =?UTF-8?q?=E5=AF=B9=E5=90=8C=E4=B8=80InterestDirection=E6=9E=9A=E4=B8=BE?= =?UTF-8?q?=E5=BF=85=E9=A1=BB=E5=8F=8D=E5=8F=B7(=E5=88=A9=E6=81=AF=3D?= =?UTF-8?q?=E4=B9=B0=E6=96=B9=E6=8C=81=E6=9C=89=E6=A0=87=E7=9A=84=E5=90=91?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E5=95=86=E8=9E=8D=E8=B5=84=E7=9A=84=E6=88=90?= =?UTF-8?q?=E6=9C=AC,=E4=BF=9D=E8=AF=81=E9=87=91=E8=BF=94=E8=BF=98=3D?= =?UTF-8?q?=E9=80=80=E5=AE=A2=E6=88=B7=E8=87=AA=E5=B7=B1=E4=BA=A4=E7=9A=84?= =?UTF-8?q?=E9=92=B1,=E4=B8=9A=E5=8A=A1=E7=A1=AE=E8=AE=A4=E9=9D=9E?= =?UTF-8?q?=E7=AC=94=E8=AF=AF),=E4=B8=8D=E5=8F=98=E9=87=8F=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E4=B8=A4=E7=AC=A6=E5=8F=B7=E4=B9=8B=E5=92=8C=E6=81=92?= =?UTF-8?q?0,=E9=A1=BA=E6=89=8B=E7=BB=9F=E4=B8=80=E5=BF=85=E6=8C=82?= =?UTF-8?q?=EF=BC=9BinterestPnlSign=E5=AF=B9=E5=88=A9=E6=81=AF/=E4=BF=9D?= =?UTF-8?q?=E8=AF=81=E9=87=91=E4=B8=A4=E8=85=BF=E9=80=9A=E7=94=A8(changeIn?= =?UTF-8?q?terestAmount=E6=A8=A1=E6=9D=BF=E5=AF=B9=E4=B8=A4=E7=B1=BB?= =?UTF-8?q?=E8=A1=8C=E9=83=BD=E7=BB=91=E5=AE=9A,=E5=B7=B2=E6=A0=B8?= =?UTF-8?q?=E5=AE=9E)=EF=BC=9B=E5=8F=A6=E5=88=A0=E6=AD=BB=E4=BB=A3?= =?UTF-8?q?=E7=A0=81:this.ratio/this.shortRatio=E6=95=B0=E6=8D=AE=E5=B1=9E?= =?UTF-8?q?=E6=80=A7+=E8=B5=8B=E5=80=BC=E5=85=A8=E5=BA=93=E6=97=A0?= =?UTF-8?q?=E8=AF=BB=E5=8F=96(=E6=A8=A1=E6=9D=BF=E4=BA=A6=E6=97=A0?= =?UTF-8?q?=E7=BB=91=E5=AE=9A)=E5=8F=8A=E6=B3=A8=E9=87=8A=E6=8E=89?= =?UTF-8?q?=E7=9A=84=E5=8E=86=E5=8F=B2=E4=B8=89=E5=85=83=EF=BC=9BSwapUnwin?= =?UTF-8?q?d.cshtml=E8=A1=A5script=E6=A0=87=E7=AD=BE,=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=9E=8B=E6=B5=8B=E8=AF=95=E6=B2=99=E7=AE=B1?= =?UTF-8?q?=E5=96=82=E7=9C=9F=E5=AE=9E=E6=A8=A1=E5=9D=97=EF=BC=9B=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E7=AD=89=E4=BB=B7(Number=3D=3D=3D1=E4=B8=8E=3D=3D1?= =?UTF-8?q?=E5=AF=B9=E5=AE=9E=E9=99=85=E5=85=A5=E5=8F=82=E7=AD=89=E4=BB=B7?= =?UTF-8?q?=E5=B7=B2=E9=AA=8C=E8=AF=81),=E5=85=A8=E5=A5=97315=E4=BE=8B?= =?UTF-8?q?=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml | 1 + .../closePercentInterestRefresh.test.js | 2 + YLErpWeb/fe-tests/unwindLegSign.test.js | 47 +++++++++++++++++++ YLErpWeb/fe-tests/unwindSwapTrade.test.js | 2 + .../Scripts/app/swaptrade/unwindLegSign.js | 45 ++++++++++++++++++ .../Scripts/app/swaptrade/unwindSwapTrade.js | 18 +++---- 6 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 YLErpWeb/fe-tests/unwindLegSign.test.js create mode 100644 YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js diff --git a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml index c1fdaf8d..353c255e 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml @@ -27,6 +27,7 @@ + }
diff --git a/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js b/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js index 2fb4a7eb..5d31102f 100644 --- a/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js +++ b/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js @@ -155,6 +155,8 @@ function loadVueApp(model, mockPost) { roundExitYtm(v) { return v; }, applyManualEdit(s) { return s; } }, + // 收付方向符号:纯函数零依赖,直接喂真实模块(映射冻结由 unwindLegSign.test.js 覆盖) + UnwindLegSign: require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'), _: { round(value, precision) { return Number(Number(value || 0).toFixed(precision || 0)); diff --git a/YLErpWeb/fe-tests/unwindLegSign.test.js b/YLErpWeb/fe-tests/unwindLegSign.test.js new file mode 100644 index 00000000..3258511c --- /dev/null +++ b/YLErpWeb/fe-tests/unwindLegSign.test.js @@ -0,0 +1,47 @@ +/** + * unwindLegSign.test.js — 平仓页收付方向符号映射(隐式约定显式化) + * ============================================================================ + * 冻结的领域事实(2026-08-21 业务确认): + * 利息腿(融资成本)与保证金腿(返息/返还本金)方向【必须相反】—— + * 利息是买方持有标的向交易商融资的成本(买方付出去的钱); + * 保证金是客户自己交的抵押金,返息/返还是把客户自己的钱退回来。 + * 两者对同一 InterestDirection 枚举符号互为镜像,这是业务事实而非笔误, + * 任何人"顺手统一"这两个符号都会翻转保证金返还方向(资损级 bug)。 + * + * 另冻结:PayDirection==1 → +1、PositionType==1(多头) → +1 的浮动端/多空符号。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest unwindLegSign + */ +const UnwindLegSign = require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'); + +describe('收付方向符号:各枚举映射', () => { + test('利息盈亏(利息腿/保证金腿通用):收取(1)→+1,支付(其他)→-1', () => { + expect(UnwindLegSign.interestPnlSign(1)).toBe(1); + expect(UnwindLegSign.interestPnlSign(0)).toBe(-1); + expect(UnwindLegSign.interestPnlSign(null)).toBe(-1); + }); + + test('保证金腿返还本金:与利息腿同枚举反号(返的是客户自己的钱)', () => { + expect(UnwindLegSign.marginRebatePrincipalSign(1)).toBe(-1); + expect(UnwindLegSign.marginRebatePrincipalSign(0)).toBe(1); + expect(UnwindLegSign.marginRebatePrincipalSign(null)).toBe(1); + }); + + test('浮动端收付:PayDirection==1(收取)→+1;多空:PositionType==1(多头)→+1', () => { + expect(UnwindLegSign.payDirectionSign(1)).toBe(1); + expect(UnwindLegSign.payDirectionSign(0)).toBe(-1); + expect(UnwindLegSign.positionTypeSign(1)).toBe(1); + expect(UnwindLegSign.positionTypeSign(0)).toBe(-1); + }); +}); + +describe('不变量:利息腿与保证金腿符号互为镜像(业务事实,禁止统一)', () => { + test('同一 InterestDirection 下两符号之和恒为 0', () => { + [1, 0, null, undefined, 2].forEach(dir => { + expect( + UnwindLegSign.interestPnlSign(dir) + + UnwindLegSign.marginRebatePrincipalSign(dir) + ).toBe(0); + }); + }); +}); diff --git a/YLErpWeb/fe-tests/unwindSwapTrade.test.js b/YLErpWeb/fe-tests/unwindSwapTrade.test.js index f0774f96..488a0c3b 100644 --- a/YLErpWeb/fe-tests/unwindSwapTrade.test.js +++ b/YLErpWeb/fe-tests/unwindSwapTrade.test.js @@ -66,6 +66,8 @@ function loadUnwindHelpers() { roundHalfAwayFromZero(value) { return value; }, calcCloseQtyByOriginalPercent() { return 0; } }, + // 收付方向符号:纯函数零依赖,直接喂真实模块(映射冻结由 unwindLegSign.test.js 覆盖) + UnwindLegSign: require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'), _: { round(value, precision) { return Number(Number(value || 0).toFixed(precision || 0)); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js new file mode 100644 index 00000000..a07d62cd --- /dev/null +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindLegSign.js @@ -0,0 +1,45 @@ +/** + * unwindLegSign.js — 平仓页收付方向符号(纯函数) + * ============================================================================ + * 把散落在 unwindSwapTrade.js 各计算式里的裸三元(xxx==1 ? ±1 : ∓1)显式化为命名函数。 + * 【为什么必须显式化】利息盈亏与保证金返还本金对同一 InterestDirection 枚举符号相反—— + * 这是业务事实而非笔误,任何"顺手统一"都会翻转保证金返还方向(资损级 bug): + * 利息盈亏(利息腿/保证金腿通用,changeInterestAmount 对两类行都生效): + * 收益互换中买方为持有标的向交易商融资,利息=融资成本(买方付出去的钱), + * InterestDirection==1(收取) → 盈亏记 +1; + * 保证金返还本金:保证金是客户自己交的抵押金,返息/返还本金是把客户自己的钱退回来, + * 本金流方向与利息(成本)相反 → 同枚举符号取镜像 -1。 + * + * 其余两个符号:PayDirection==1(浮动端收取) → +1;PositionType==1(多头) → +1。 + * + * 加载:浏览器 script 标签(先于 unwindSwapTrade.js);Node:module.exports 供 jest。 + */ +var UnwindLegSign = (function () { + 'use strict'; + + // Number()===1 与页面原 ==1 对 '1'/true/数字等实际入参等价,且对 null/undefined 同样落到 -1 分支 + function interestPnlSign(interestDirection) { + return Number(interestDirection) === 1 ? 1 : -1; + } + + function marginRebatePrincipalSign(interestDirection) { + return Number(interestDirection) === 1 ? -1 : 1; + } + + function payDirectionSign(payDirection) { + return Number(payDirection) === 1 ? 1 : -1; + } + + function positionTypeSign(positionType) { + return Number(positionType) === 1 ? 1 : -1; + } + + return Object.freeze({ + interestPnlSign: interestPnlSign, + marginRebatePrincipalSign: marginRebatePrincipalSign, + payDirectionSign: payDirectionSign, + positionTypeSign: positionTypeSign + }); +}()); + +if (typeof module === 'object' && module.exports) module.exports = UnwindLegSign; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js index 764e9623..40b5da7c 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -69,8 +69,6 @@ const vue = new Vue({ // 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。 // oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。 oriClosePercent: 1, - ratio: 1, - shortRatio: 1, }, computed: { maxUnwindDate() { @@ -125,8 +123,6 @@ const vue = new Vue({ this.marginList = model.FlowEvents.filter((item) => { return item.InterestMode == 5 || item.InterestMode == 6; }); - this.ratio = this.floatPosition.PayDirection == 1 ? -1 : 1; - this.shortRatio = this.floatPosition.PositionType == 1 ? 1 : -1; this.TradeStartDate = model.TradeStartDate; // 最多可平比例(占期初口径) = 剩余名义本金 / 期初名义本金;分母为 0 时兜底为 1 this.oriClosePercent = (this.deal.NotionalValue && this.deal.PosiNotionalValue) @@ -424,8 +420,8 @@ const vue = new Vue({ }, calcFloatClosePnl() {//计算浮动端平仓盈亏 var thisObj = this; - let floatRatio = thisObj.floatPosition.PayDirection == 1 ? 1 : -1; - let longRatio = thisObj.floatPosition.PositionType == 1 ? 1 : -1; + let floatRatio = UnwindLegSign.payDirectionSign(thisObj.floatPosition.PayDirection); + let longRatio = UnwindLegSign.positionTypeSign(thisObj.floatPosition.PositionType); let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee); let TradingFeePending = thisObj.floatPosition.TradingFeePending == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFeePending); let deliveryPrice = thisObj.getStorageDeliveryPrice(); @@ -440,7 +436,7 @@ const vue = new Vue({ this.calcFloatClosePnl(); }, changeInterestAmount(item) {//修改利息金额 - let interestRatio = item.InterestDirection == 1 ? 1 : -1; + let interestRatio = UnwindLegSign.interestPnlSign(item.InterestDirection); item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee) * interestRatio); this.calcCloseAmount(); }, @@ -450,8 +446,8 @@ const vue = new Vue({ // this.calcCloseAmount(); //}, calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付 - let floatRatio = this.floatPosition.PayDirection == 1 ? 1 : -1; - let ratio = this.floatPosition.PositionType == 1 ? 1 : -1; + let floatRatio = UnwindLegSign.payDirectionSign(this.floatPosition.PayDirection); + let ratio = UnwindLegSign.positionTypeSign(this.floatPosition.PositionType); let thisObj = this; let pnl = parseFloat(this.floatPosition.FloatPnlSum); let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee); @@ -468,14 +464,14 @@ const vue = new Vue({ thisObj.floatPosition.TradingAmountFeeAvg = deliveryPrice + (TradingFee / thisObj.deal.CloseQty) * ratio; } this.interestList.forEach(x => { - /*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/ let interestAmount = parseFloat(x.InterestClosePnL); thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount; }); this.marginList.forEach(x => { let interestAmount = parseFloat(x.InterestClosePnL); - let interestRatio = x.InterestDirection == 1 ? -1 : 1; + // 保证金返还本金符号与利息盈亏同枚举反号(见 unwindLegSign.js 头注——业务事实勿统一) + let interestRatio = UnwindLegSign.marginRebatePrincipalSign(x.InterestDirection); thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount; thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount; thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount;