From 94827eb6b668d7b6c9653d1ab998f6bcc0d3e7fd Mon Sep 17 00:00:00 2001 From: hjhan Date: Wed, 15 Jul 2026 11:00:07 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(fast):=20=E4=BF=AE=E5=A4=8D=20numberInp?= =?UTF-8?q?ut=20=E7=B2=98=E8=B4=B4=E8=B7=AF=E5=BE=84=20percent=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20=C3=97100=EF=BC=8C=E5=B9=B6=E8=A1=A5=20Jest=20?= =?UTF-8?q?=E7=B2=98=E8=B4=B4=E5=9B=9E=E5=BD=92=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fastVue.base.js: __onInput 粘贴分支原把显示值当模型值传入 setValue 导致 percent 字段 ×100; 改为先按 __change 同款口径(去千分位逗号 + 按 percent/append:'%'/append:'‱' 除法)解析为模型值再回显, 使 DOM/显示/模型三者一致;键盘输入路径不变 - 重新生成 Statics/bundles/*.js:去每个输入文件头的 UTF-8 BOM(避免 bundle 中间出现 ZWN BSP 不可见字符), 页面实际加载 bundle.js(改源文件必须重生成 bundle 才生效) - 新增 numberInput.paste.test.js 覆盖 percent:true/append:%/append:‱/普通数字 4 类字段的 Ctrl+V 粘贴(Jest+jsdom,4 passed) - fe-tests package.json 增加 jest-environment-jsdom、jquery 依赖 --- YLErpWeb/wwwroot/Statics/bundles/bundle.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/YLErpWeb/wwwroot/Statics/bundles/bundle.js b/YLErpWeb/wwwroot/Statics/bundles/bundle.js index a7e28814..06ac26d4 100644 --- a/YLErpWeb/wwwroot/Statics/bundles/bundle.js +++ b/YLErpWeb/wwwroot/Statics/bundles/bundle.js @@ -136,8 +136,11 @@ u=-1,i=t[o];++u=e?t:""+Array(e+1-r.length).join(n)+t},l={s:$,z:function(t){var e=-t.utcOffset(),n=Math.abs(e),r=Math.floor(n/60),i=n%60;return(e<=0?"+":"-")+$(r,2,"0")+":"+$(i,2,"0")},m:function t(e,n){if(e.date()4&&(r+=7),n.add(r,t));return o.diff(u,"week")+1},d.isoWeekday=function(t){return this.$utils().u(t)?this.day()||7:this.day(this.day()%7?t:t-7)};var n=d.startOf;d.startOf=function(t,e){var i=this.$utils(),s=!!i.u(e)||e;return"isoweek"===i.p(t)?s?this.date(this.date()-(this.isoWeekday()-1)).startOf("day"):this.date(this.date()-1-(this.isoWeekday()-1)+7).endOf("day"):n.bind(this)(t,e)}}}); + dayjs.extend(dayjs_plugin_weekday); dayjs.extend(dayjs_plugin_isoWeek); /*! @@ -280,6 +283,7 @@ $.extend($.jgrid,{ } }); })(jQuery); + /* * jQuery UI Multiselect * @@ -596,6 +600,7 @@ $.extend($.ui.multiselect, { }); })(jQuery); + // ==ClosureCompiler== // @compilation_level SIMPLE_OPTIMIZATIONS @@ -13298,6 +13303,7 @@ jQuery(selector).each(function() { } }); }; + ;(function($){ /** * jqGrid extension @@ -14483,7 +14489,6 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru function __onInput() { if (_ctrlV) { _ctrlV = false; - console.log('[numberInput paste] raw=', this.value, 'append=', _options.append, 'percent=', _options.percent); // 粘贴进来的是显示值(可能带 %/‱ 后缀),先按 __change 同款口径解析为模型值再回显, // 避免 setValue 把显示值再乘以 100/10000(#EQD-5914 债券价格粘贴 ×100) let f = ''; @@ -14492,7 +14497,6 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru if (_options.append === '%' || _options.percent == true) f /= 100; else if (_options.append === '‱') f /= 10000; } - console.log('[numberInput paste] parsed=', f); return setValue(f); } if (_chnInput >= 0) { @@ -14515,7 +14519,6 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru //输入完成处理 function __change() { - console.log('[numberInput change] raw=', this.value, 'append=', _options.append, 'percent=', _options.percent); let f = ''; if (this.value && this.value !== _options.append) { f = parseFloat(this.value.replaceAll(",", "")) || 0; @@ -14523,7 +14526,6 @@ $.fn.selectpicker.Constructor.DEFAULTS = Object.assign($.fn.selectpicker.Constru else if (_options.append === '‱') f /= 10000; } - console.log('[numberInput change] parsed=', f, 'value=', this.value); _options.onchange && _options.onchange(f, this.value); } @@ -15753,6 +15755,7 @@ if (!String.prototype.replaceAll) { return this.replace(new RegExp(oldString, "gm"), newString) } } + var main = main || {}; (function ($) { @@ -15865,4 +15868,4 @@ var main = main || {}; }; }; -})(jQuery); +})(jQuery); \ No newline at end of file From d69faaa8e830a240f02eb4cce18a733b14b95b4a Mon Sep 17 00:00:00 2001 From: hjhan Date: Wed, 15 Jul 2026 11:00:07 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(fast):=20=E4=BF=AE=E5=A4=8D=20numberInp?= =?UTF-8?q?ut=20=E7=B2=98=E8=B4=B4=E8=B7=AF=E5=BE=84=20percent=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20=C3=97100=EF=BC=8C=E5=B9=B6=E8=A1=A5=20Jest=20?= =?UTF-8?q?=E7=B2=98=E8=B4=B4=E5=9B=9E=E5=BD=92=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fastVue.base.js: __onInput 粘贴分支原把显示值当模型值传入 setValue 导致 percent 字段 ×100; 改为先按 __change 同款口径(去千分位逗号 + 按 percent/append:'%'/append:'‱' 除法)解析为模型值再回显, 使 DOM/显示/模型三者一致;键盘输入路径不变 - 重新生成 Statics/bundles/bundle.js:去每个输入文件头的 UTF-8 BOM(避免 bundle 中间出现 ZWN BSP 不可见字符), 页面实际加载 bundle.js(改源文件必须重生成 bundle 才生效) - 新增 numberInput.paste.test.js 覆盖 percent:true/append:%/append:‱/普通数字 4 类字段的 Ctrl+V 粘贴(Jest+jsdom,4 passed) - fe-tests package.json 增加 jest-environment-jsdom、jquery 依赖 --- YLErpWeb/wwwroot/Scripts/fast/fastVue.base.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/YLErpWeb/wwwroot/Scripts/fast/fastVue.base.js b/YLErpWeb/wwwroot/Scripts/fast/fastVue.base.js index 6c4f3c64..f57f9c4a 100644 --- a/YLErpWeb/wwwroot/Scripts/fast/fastVue.base.js +++ b/YLErpWeb/wwwroot/Scripts/fast/fastVue.base.js @@ -432,7 +432,6 @@ function __onInput() { if (_ctrlV) { _ctrlV = false; - console.log('[numberInput paste] raw=', this.value, 'append=', _options.append, 'percent=', _options.percent); // 粘贴进来的是显示值(可能带 %/‱ 后缀),先按 __change 同款口径解析为模型值再回显, // 避免 setValue 把显示值再乘以 100/10000(#EQD-5914 债券价格粘贴 ×100) let f = ''; @@ -441,7 +440,6 @@ if (_options.append === '%' || _options.percent == true) f /= 100; else if (_options.append === '‱') f /= 10000; } - console.log('[numberInput paste] parsed=', f); return setValue(f); } if (_chnInput >= 0) { @@ -464,7 +462,6 @@ //输入完成处理 function __change() { - console.log('[numberInput change] raw=', this.value, 'append=', _options.append, 'percent=', _options.percent); let f = ''; if (this.value && this.value !== _options.append) { f = parseFloat(this.value.replaceAll(",", "")) || 0; @@ -472,7 +469,6 @@ else if (_options.append === '‱') f /= 10000; } - console.log('[numberInput change] parsed=', f, 'value=', this.value); _options.onchange && _options.onchange(f, this.value); } From 4f46e2634f3c3bf98573d9561dde195aff43b195 Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 16 Jul 2026 10:03:21 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=94=B6=E7=9B=8A?= =?UTF-8?q?=E4=BA=92=E6=8D=A2=E5=A4=9A=E6=AC=A1=E9=83=A8=E5=88=86=E5=B9=B3?= =?UTF-8?q?=E4=BB=93=E9=A2=84=E4=BB=98=E9=87=91=E8=BF=94=E8=BF=98=EF=BC=9A?= =?UTF-8?q?=E5=B9=B3=E4=BB=93=E6=AF=94=E4=BE=8B/=E5=90=8D=E4=B9=89?= =?UTF-8?q?=E6=9C=AC=E9=87=91=E6=94=B9=E4=BB=A5=E5=89=A9=E4=BD=99=E6=8C=81?= =?UTF-8?q?=E4=BB=93=E4=B8=BA=E5=9F=BA=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unwindSwapTrade.js: - oriClosePercent 恒为 1(Definition B 下最多平 100% 剩余,修正全部平仓/按比例少返预付金 + 进页面 getInterestList 少算) - CloseMethod==1(全部平仓)时修正 ClosePercent=1;CloseMethod==2(部分平仓/待复核)保留已提交比例,避免覆盖 - changeCloseQty/Percent/NotionalValue 三处换算改用 PosiNotionalValue(剩余本金),不再用原始 NotionalValue swapCalc.js: 新增 calcCloseNotionalByRemaining / calcClosePercentByRemaining 纯函数(含除零保护) swapCalc.test.js + _shim_run.js: 补充多次部分平仓换算单测(剩余vs原始对比、第3次全平、3次合计=原始、除零保护) --- YLErpWeb/fe-tests/_shim_run.js | 4 ++ YLErpWeb/fe-tests/swapCalc.test.js | 58 +++++++++++++++++++ .../wwwroot/Scripts/app/swaptrade/swapCalc.js | 15 +++++ .../Scripts/app/swaptrade/unwindSwapTrade.js | 19 ++++-- 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/YLErpWeb/fe-tests/_shim_run.js b/YLErpWeb/fe-tests/_shim_run.js index 5f4cfbb9..93bcb82a 100644 --- a/YLErpWeb/fe-tests/_shim_run.js +++ b/YLErpWeb/fe-tests/_shim_run.js @@ -24,6 +24,10 @@ function makeExpect(actual) { if (actual <= n) { passed++; } else { failed++; failures.push('toBeLessThanOrEqual: ' + fmt(actual) + ' <= ' + n + ' failed'); } }, + toBeGreaterThan(n) { + if (actual > n) { passed++; } + else { failed++; failures.push('toBeGreaterThan: ' + fmt(actual) + ' > ' + n + ' failed'); } + }, toBeGreaterThanOrEqual(n) { if (actual >= n) { passed++; } else { failed++; failures.push('toBeGreaterThanOrEqual: ' + fmt(actual) + ' >= ' + n + ' failed'); } diff --git a/YLErpWeb/fe-tests/swapCalc.test.js b/YLErpWeb/fe-tests/swapCalc.test.js index 7d3dca9b..6e2c059f 100644 --- a/YLErpWeb/fe-tests/swapCalc.test.js +++ b/YLErpWeb/fe-tests/swapCalc.test.js @@ -43,6 +43,64 @@ describe('回归守卫:曾出 bug 的纯函数', () => { }); }); +// ============================================================================ +// 多次部分平仓:平仓比例 ↔ 平仓名义本金 换算必须以"剩余持仓名义本金"为基准 +// 守卫 unwindSwapTrade.js changeCloseQty/changeClosePercent/changeCloseNotionalValue +// 旧 bug:用原始 NotionalValue 换算,首次部分平仓后 PosiNotionalValue 被扣减, +// 导致 ClosePercent 偏小 / CloseNotionalValue 偏大,后端预付金返还本金计算错误 +// ============================================================================ +describe('多次部分平仓:比例↔名义本金换算以 PosiNotionalValue 为基准', () => { + // 场景:原始名义本金 1,000,000,首次平 30% 后剩余 700,000 + // 第 2 次按名义本金输入 350,000(意图平剩余 50%) + const NotionalValue = 1_000_000; // 原始(不变) + const PosiNotionalValue = 700_000; // 首次部分平仓后剩余 + + test('按名义本金反算比例:应=0.5(用剩余),旧 bug 会算成 0.35(用原始)', () => { + const closeNotionalValue = 350_000; + const correctPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, PosiNotionalValue); + const buggyPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, NotionalValue); + expectClose(correctPct, 0.5, '剩余本金为分母应得 50%'); + expectClose(buggyPct, 0.35, '旧 bug 用原始本金会算成 0.35'); + expect(Math.abs(correctPct - buggyPct)).toBeGreaterThan(TOL); + }); + + test('按比例正算名义本金:应=350,000(用剩余),旧 bug 会算成 500,000(用原始)', () => { + const closePercent = 0.5; + const correctNotional = SwapCalc.calcCloseNotionalByRemaining(closePercent, PosiNotionalValue); + const buggyNotional = SwapCalc.calcCloseNotionalByRemaining(closePercent, NotionalValue); + expectClose(correctNotional, 350_000, '剩余本金×50%应=350,000'); + expectClose(buggyNotional, 500_000, '旧 bug 用原始本金会算成 500,000'); + expect(Math.abs(correctNotional - buggyNotional)).toBeGreaterThan(TOL); + }); + + test('第 3 次全平剩余 350,000:比例应=1.0(用剩余),旧 bug 会算成 0.35', () => { + const remaining = 350_000; + const closeNotionalValue = 350_000; + const correctPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, remaining); + const buggyPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, NotionalValue); + expectClose(correctPct, 1.0, '剩余全平应=100%'); + expectClose(buggyPct, 0.35, '旧 bug 用原始本金会算成 0.35'); + }); + + test('多次部分平仓合计本金应=原始名义本金(3 次:30% / 50% / 全平)', () => { + // 模拟 3 次部分平仓的换算,每次以"当前剩余"为基准 + let remaining = 1_000_000; + const steps = [0.3, 0.5, 1.0]; + let totalClosed = 0; + steps.forEach((pct) => { + const closed = SwapCalc.calcCloseNotionalByRemaining(pct, remaining); + totalClosed += closed; + remaining -= closed; + }); + expectClose(totalClosed, 1_000_000, '3 次部分平仓合计应=原始名义本金'); + expectClose(remaining, 0, '剩余应为 0'); + }); + + test('除零保护:剩余本金为 0 时比例返回 0', () => { + expect(SwapCalc.calcClosePercentByRemaining(100, 0)).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 d9f6edbd..e41565df 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -65,6 +65,19 @@ return roundHalfAwayFromZero(posiGrossPrice * factor, 2); } + // 平仓名义本金 = 平仓比例 × 剩余持仓名义本金(PosiNotionalValue) + // 多次部分平仓后必须用剩余本金 PosiNotionalValue,不能用原始 NotionalValue,否则偏大 + function calcCloseNotionalByRemaining(closePercent, posiNotionalValue) { + return roundHalfAwayFromZero(Number(closePercent) * Number(posiNotionalValue), 2); + } + + // 平仓比例 = 平仓名义本金 / 剩余持仓名义本金(PosiNotionalValue) + // 多次部分平仓后必须以剩余本金为分母,否则比例偏小,导致后端预付金返还本金计算错误 + function calcClosePercentByRemaining(closeNotionalValue, posiNotionalValue) { + if (Number(posiNotionalValue) === 0) return 0; + return roundHalfAwayFromZero(Number(closeNotionalValue) / Number(posiNotionalValue), 6); + } + // 盯市平仓盈亏(unwind):CloseQty × (期末全价×scale − 期初全价) × floatRatio × longRatio // 对齐 FrontendCalcReference.CalcUnwind:先 ×10000 取整再 ÷10000,最后 toFixed(2) // 干净输入下等价于直接 round(.., 2) @@ -164,6 +177,8 @@ roundMoney: roundMoney, calcFloatPnlSum: calcFloatPnlSum, calcStockEqvNotional: calcStockEqvNotional, + calcCloseNotionalByRemaining: calcCloseNotionalByRemaining, + calcClosePercentByRemaining: calcClosePercentByRemaining, 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 b97414cf..e1fce1dc 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js @@ -17,7 +17,9 @@ const vue = new Vue({ marginList: [], initPosiNetPrice: 0, multiplier: 1, - oriClosePercent: model.ClosePercent, + // 多次部分平仓后,ClosePercent 语义为“占剩余持仓的比例”(后端 GetUnwindInterests 用 remainingBase×closePercent 计算预付金返还), + // 故最多可平 100% 剩余,oriClosePercent 恒为 1;不能用 model.ClosePercent(=剩余/原始,Definition A 旧口径),否则全部平仓/按比例会少返预付金 + oriClosePercent: 1, ratio: 1, shortRatio: 1, }, @@ -53,6 +55,12 @@ const vue = new Vue({ this.ratio = this.floatPosition.PayDirection == 1 ? -1 : 1; this.shortRatio = this.floatPosition.PositionType == 1 ? 1 : -1; this.TradeStartDate = model.TradeStartDate; + // 多次部分平仓后 ClosePercent 语义为"占剩余持仓比例"。 + // 仅当"全部平仓"(CloseMethod==1) 时修正旧口径(model.ClosePercent 可能=剩余/原始<1)为 1; + // "部分平仓"(CloseMethod==2) 时保留已提交比例(平仓待复核场景),避免覆盖用户已提交的 closePercent + if (this.deal.CloseMethod === 1) { + this.deal.ClosePercent = 1; + } // 转换期末标的价格为百分比形式 if (this.floatPosition.TradingAmountAvg) { this.floatPosition.TradingAmountAvg = this.floatPosition.TradingAmountAvg * this.multiplier; @@ -146,7 +154,8 @@ const vue = new Vue({ } else { this.deal.CloseMethod = 2; } - this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue)); + // 多次部分平仓后 PosiNotionalValue 才是剩余本金,不能用原始 NotionalValue,否则平仓名义本金偏大 + this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.PosiNotionalValue)); this.calcTradingFeePending(); this.getInterestList(); this.calcFloatClosePnl(); @@ -158,7 +167,8 @@ const vue = new Vue({ return; } this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent)); - this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue)); + // 多次部分平仓后 PosiNotionalValue 才是剩余本金,不能用原始 NotionalValue,否则平仓名义本金偏大 + this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.PosiNotionalValue)); if (parseFloat(this.deal.CloseNotionalValue) == parseFloat(this.deal.PosiNotionalValue)) { this.floatPosition.CloseMethod = 1; } else { @@ -174,7 +184,8 @@ const vue = new Vue({ this.deal.CloseNotionalValue = this.deal.PosiNotionalValue; return; } - this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseNotionalValue) / parseFloat(this.deal.NotionalValue)); + // 多次部分平仓后应以 PosiNotionalValue(剩余) 为分母,否则 ClosePercent 偏小,导致后端预付金返还本金计算错误 + this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseNotionalValue) / parseFloat(this.deal.PosiNotionalValue)); this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent)); this.calcTradingFeePending(); this.getInterestList();