/** * swapCalc.test.js — 前端计算逻辑单元测试 * ============================================================================ * 双重目的: * 1) 回归守卫:锁定 4 个曾出 bug 的纯函数(dcf649f2 / 20ea93d8 / 3c5f25a5 / f873239a) * 2) 交叉校验:用 8 个场景(FC_001~FC_008)对齐 C# FrontendCalcCharacterizationTest 金标准, * 一旦 JS 公式与后端 FrontendCalcReference 分叉,测试即红。 * * 运行:cd YLErpWeb/fe-tests && npm i && npm test */ const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js'); const TOL = 1e-6; function expectClose(actual, expected, msg) { expect(Math.abs(actual - expected)).toBeLessThanOrEqual(TOL, msg || ''); } describe('回归守卫:曾出 bug 的纯函数', () => { // 20ea93d8 / dcf649f2:必须用全价(PosiGrossPrice) 且债券 ×100 test('deriveTradingAmountAvg 债券用全价并 ×100 (守卫 20ea93d8/dcf649f2)', () => { expectClose(SwapCalc.deriveTradingAmountAvg(1.02, 100), 102, '债券: 1.02×100=102(界面百分比态)'); expectClose(SwapCalc.deriveTradingAmountAvg(1.02, 1), 1.02, '非债券: 不缩放'); // 若误用净价(PosiNetPrice) 会偏离,这里锁定"全价"语义 expect(SwapCalc.deriveTradingAmountAvg(1.02, 100)).toBe(102); }); test('收益结算审批回显提交的期末价格', () => { expectClose( SwapCalc.resolveIncomeTradingAmountAvg(0.98, 0.80, 100, true), 80, '债券审批应把已提交相对价0.80还原为界面值80'); expectClose( SwapCalc.resolveIncomeTradingAmountAvg(0.98, 0.80, 100, false), 98, '普通打开仍以期初全价98作为默认期末价'); }); // 3c5f25a5:FloatPnlSum 必须保留 2 位小数 test('calcFloatPnlSum 保留 2 位 (守卫 3c5f25a5)', () => { expectClose(SwapCalc.calcFloatPnlSum(100.456, 1, 0.5, 0), 101.96, '100.456+1+0.5=101.956→101.96'); expectClose(SwapCalc.calcFloatPnlSum(30, 20, 0, 0), 50, '30+20=50'); expect(SwapCalc.calcFloatPnlSum(30, 20, 0, 0)).toBe(50); }); // f873239a:名义本金 round 到 2 位 test('calcStockEqvNotional round 2 位 (守卫 f873239a)', () => { expectClose(SwapCalc.calcStockEqvNotional(1.02, 100 * 100), 10200, '1.02×10000=10200'); expectClose(SwapCalc.calcStockEqvNotional(10.005, 100), 1000.5, '10.005×100=1000.50'); }); test('getPriceScale 债券=0.01 非债券=1', () => { expect(SwapCalc.getPriceScale(100)).toBe(0.01); expect(SwapCalc.getPriceScale(1)).toBe(1); }); }); // ============================================================================ // 多次部分平仓:平仓比例 ↔ 平仓名义本金 换算必须以"剩余持仓名义本金"为基准 // 守卫 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); }); }); // ============================================================================ // 多次部分平仓:全部↔部分切换时 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); }); // 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 金标准', () => { // FC_001 平仓-债券多头-默认 test('FC_001 平仓 债券多头 默认', () => { const r = SwapCalc.calcUnwind({ multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, closeQty: 1000, payDirection: 1, positionType: 1, tradingFee: '20', tradingFeePending: '0', dividendIn: '0' }); expectClose(r.MarkClosePnl, 30, 'MarkClosePnl'); expectClose(r.FloatPnlSum, 50, 'FloatPnlSum'); expectClose(r.SwapRealizedPnL, 50, 'SwapRealizedPnL'); }); // FC_002 改标的价格 105→110 test('FC_002 平仓 改标的价格', () => { const r = SwapCalc.calcUnwind({ multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 110, closeQty: 1000, payDirection: 1, positionType: 1, tradingFee: '20', tradingFeePending: '0', dividendIn: '0' }); expectClose(r.MarkClosePnl, 80, 'MarkClosePnl'); expectClose(r.FloatPnlSum, 100, 'FloatPnlSum'); }); // FC_003 改平仓数量 1000→500 test('FC_003 平仓 改平仓数量', () => { const r = SwapCalc.calcUnwind({ multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, closeQty: 500, payDirection: 1, positionType: 1, tradingFee: '20', tradingFeePending: '10', dividendIn: '0' }); expectClose(r.MarkClosePnl, 15, 'MarkClosePnl'); expectClose(r.FloatPnlSum, 45, 'FloatPnlSum'); }); // FC_004 改利息金额 +100 test('FC_004 平仓 改利息金额', () => { const r = SwapCalc.calcUnwind({ multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, closeQty: 1000, payDirection: 1, positionType: 1, tradingFee: '20', tradingFeePending: '0', dividendIn: '0', interestLegs: [{ interestClosePnL: 100 }] }); expectClose(r.MarkClosePnl, 30, 'MarkClosePnl 不受利息影响'); expectClose(r.SwapRealizedPnL, 150, '含利息 SwapRealizedPnL'); }); // FC_005 非债券空头 方向因子 test('FC_005 平仓 非债券空头 方向因子', () => { const r = SwapCalc.calcUnwind({ multiplier: 1, posiGrossPrice: 100, tradingAmountAvg: 105, closeQty: 1000, payDirection: 1, positionType: 2, tradingFee: '0', tradingFeePending: '0', dividendIn: '0' }); expectClose(r.MarkClosePnl, -5000, '空头价格涨=亏损'); }); // FC_006 结息 债券多头 全量 test('FC_006 结息 债券多头 全量', () => { const r = SwapCalc.calcIncome({ multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, positionQty: 10000, contractSize: 1, closeNotionalValue: 10200, closeQty: 0, payDirection: 1, positionType: 1, tradingFee: '0', tradingFeePending: '0', dividendIn: '0' }); expectClose(r.MarkClosePnl, 300, 'income MarkClosePnl'); expectClose(r.SwapRealizedPnL, 300, 'income SwapRealizedPnL'); }); // FC_007 结息 改标的价格 105→110 test('FC_007 结息 改标的价格', () => { const r = SwapCalc.calcIncome({ multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 110, positionQty: 10000, contractSize: 1, closeNotionalValue: 10200, closeQty: 0, payDirection: 1, positionType: 1, tradingFee: '0', tradingFeePending: '0', dividendIn: '0' }); expectClose(r.MarkClosePnl, 800, '改价格后 income MarkClosePnl'); }); // FC_008 结息 含利息腿+预付金腿 test('FC_008 结息 含利息腿与预付金腿 总额', () => { const r = SwapCalc.calcIncome({ multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, positionQty: 10000, contractSize: 1, closeNotionalValue: 10200, closeQty: 0, payDirection: 1, positionType: 1, tradingFee: '0', tradingFeePending: '0', dividendIn: '0', interestLegs: [{ interestClosePnL: 100 }], marginLegs: [{ interestClosePnL: 50 }] }); expectClose(r.SwapRealizedPnL, 450, '含利息+预付金 SwapRealizedPnL'); expectClose(r.SwapMarginRebatePnl, 50, 'SwapMarginRebatePnl'); }); // FC_009 真实回归:债券结息价差按数量计算,不能误用期初名义本金 test('FC_009 结息 债券价差按数量计算', () => { const r = SwapCalc.calcIncome({ multiplier: 100, posiGrossPrice: 0.98, tradingAmountAvg: 80, positionQty: 30000000, contractSize: 1, closeNotionalValue: 29400000, closeQty: 0, payDirection: 2, positionType: 1, tradingFee: '0', tradingFeePending: '0', dividendIn: '-45000' }); expectClose(r.MarkClosePnl, 5400000, 'income MarkClosePnl按数量计算'); expectClose(r.FloatPnlSum, 5355000, 'income FloatPnlSum包含分红'); }); });