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();