From 7f64d486993c9020dfe6b735664df19375d34232 Mon Sep 17 00:00:00 2001 From: hjhan Date: Tue, 7 Jul 2026 12:30:19 +0800 Subject: [PATCH] =?UTF-8?q?test(fe):=20=E6=8E=A5=E5=85=A5=20SwapCalc=20?= =?UTF-8?q?=E7=BA=AF=E8=AE=A1=E7=AE=97=E6=A8=A1=E5=9D=97=20+=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E7=B2=BE=E5=BA=A6=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - incomeSwapTrade.js / swapTradeEdit.js 改为调用 SwapCalc.*(缩放/取整/全价推导) - 新增 fe-tests 守卫:4 个历史 bug 回归 + 8 场景对齐 C# FrontendCalcReference 金标准 + otcformat.js 精度配置守卫 - 新增 .git/hooks/pre-commit 自动运行前端守卫(失败阻断提交) --- YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml | 1 + YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml | 1 + YLErpWeb/fe-tests/_shim_run.js | 50 ++++++ YLErpWeb/fe-tests/otcformat.test.js | 50 ++++++ YLErpWeb/fe-tests/package.json | 12 ++ YLErpWeb/fe-tests/parity.test.js | 96 +++++++++++ YLErpWeb/fe-tests/swapCalc.test.js | 136 +++++++++++++++ .../Scripts/app/swaptrade/incomeSwapTrade.js | 6 +- .../wwwroot/Scripts/app/swaptrade/swapCalc.js | 161 ++++++++++++++++++ .../Scripts/app/swaptrade/swapTradeEdit.js | 2 +- 10 files changed, 511 insertions(+), 4 deletions(-) create mode 100644 YLErpWeb/fe-tests/_shim_run.js create mode 100644 YLErpWeb/fe-tests/otcformat.test.js create mode 100644 YLErpWeb/fe-tests/package.json create mode 100644 YLErpWeb/fe-tests/parity.test.js create mode 100644 YLErpWeb/fe-tests/swapCalc.test.js create mode 100644 YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js diff --git a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml index 80e6ce7a..b02667f9 100644 --- a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml +++ b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml @@ -28,6 +28,7 @@ + }
diff --git a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml index 3e8660bc..b8c7d03f 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml @@ -96,6 +96,7 @@ + }
diff --git a/YLErpWeb/fe-tests/_shim_run.js b/YLErpWeb/fe-tests/_shim_run.js new file mode 100644 index 00000000..5f4cfbb9 --- /dev/null +++ b/YLErpWeb/fe-tests/_shim_run.js @@ -0,0 +1,50 @@ +/** + * _shim_run.js — 不依赖 jest 的轻量测试运行器(仅用于在沙箱内验证测试文件逻辑)。 + * 本机请用 jest:cd YLErpWeb/fe-tests && npm i && npm test + */ +let passed = 0; +let failed = 0; +const failures = []; + +function fmt(v) { + return typeof v === 'object' ? JSON.stringify(v) : String(v); +} + +function makeExpect(actual) { + return { + toBe(expected) { + if (Object.is(actual, expected)) { passed++; } + else { failed++; failures.push('toBe: expected ' + fmt(expected) + ' got ' + fmt(actual)); } + }, + toBeDefined() { + if (actual !== undefined && actual !== null) { passed++; } + else { failed++; failures.push('toBeDefined: got ' + fmt(actual)); } + }, + toBeLessThanOrEqual(n) { + if (actual <= n) { passed++; } + else { failed++; failures.push('toBeLessThanOrEqual: ' + fmt(actual) + ' <= ' + n + ' failed'); } + }, + toBeGreaterThanOrEqual(n) { + if (actual >= n) { passed++; } + else { failed++; failures.push('toBeGreaterThanOrEqual: ' + fmt(actual) + ' >= ' + n + ' failed'); } + }, + }; +} + +global.expect = (actual) => makeExpect(actual); +global.describe = (name, fn) => { fn(); }; +global.test = (name, fn) => { + try { fn(); } + catch (e) { failed++; failures.push(name + ': ' + e.message); } +}; +global.it = global.test; + +require('./swapCalc.test.js'); +require('./otcformat.test.js'); +require('./parity.test.js'); + +console.log('\n==== RUNNER (jest-shim) ===='); +console.log('PASS=' + passed + ' FAIL=' + failed); +failures.forEach((f) => console.log(' ✗ ' + f)); +console.log(failures.length ? 'RESULT: RED' : 'RESULT: GREEN'); +process.exit(failed > 0 ? 1 : 0); diff --git a/YLErpWeb/fe-tests/otcformat.test.js b/YLErpWeb/fe-tests/otcformat.test.js new file mode 100644 index 00000000..03604a40 --- /dev/null +++ b/YLErpWeb/fe-tests/otcformat.test.js @@ -0,0 +1,50 @@ +/** + * otcformat.test.js — 守卫运行时配置 otcformat.js 的精度回归 + * ============================================================================ + * 背景:088df270 中 otcformat.js 的 precision 被 Revert 误从 9 改回 2, + * 导致"期末全价只能录 2 位小数"。该 bug 已复发 2 次,纯前端/后端测试都碰不到。 + * 本测试直接解析 App_Data/Config/otcformat.js,断言关键字段 precision 不低于预期, + * 是 ROI 最高的单一守卫(零重构、1 个文件、无依赖)。 + * + * 运行:cd YLErpWeb/fe-tests && npm i && npm test + */ +const fs = require('fs'); +const path = require('path'); + +const cfgPath = path.resolve(__dirname, '../App_Data/Config/otcformat.js'); +const src = fs.readFileSync(cfgPath, 'utf8'); + +// otcformat.js 形如 `var main = main || {}; main.formatOptions = {...};` +// 在沙箱函数内求值并取出 main.formatOptions +const formatOptions = new Function(src + '\n;return main.formatOptions;')(); + +describe('otcformat.js 精度配置守卫 (088df270)', () => { + test('配置文件可被解析且含 trading 节点', () => { + expect(formatOptions).toBeDefined(); + expect(formatOptions.trading).toBeDefined(); + }); + + // 这些字段在 088df270 被误降到 2,必须保持 9(与当前全 precision=9 一致) + const mustBeNine = [ + 'umprice', 'umpriceP', 'umpricePR', + 'tradeSinglePrice', 'tradePrice', 'StockEqvNotional', 'notional', + 'premiumRate', 'premiumRateP', 'volatility', + 'marginRate', 'marginRateP', 'greek' + ]; + + mustBeNine.forEach(function (key) { + test('trading.' + key + '.precision === 9', () => { + expect(formatOptions.trading[key]).toBeDefined(); + expect(formatOptions.trading[key].precision).toBe(9); + }); + }); + + test('所有 trading 数值字段 precision 均 >= 9(防再次误降)', () => { + Object.keys(formatOptions.trading).forEach(function (key) { + const opt = formatOptions.trading[key]; + if (opt && typeof opt.precision === 'number') { + expect(opt.precision).toBeGreaterThanOrEqual(9); + } + }); + }); +}); diff --git a/YLErpWeb/fe-tests/package.json b/YLErpWeb/fe-tests/package.json new file mode 100644 index 00000000..63ecee9a --- /dev/null +++ b/YLErpWeb/fe-tests/package.json @@ -0,0 +1,12 @@ +{ + "name": "zszq-trs-fe-tests", + "version": "1.0.0", + "private": true, + "description": "前端 JS 单元测试:守卫互换结算/平仓的小数点 bug(与 C# FrontendCalcReference 交叉校验)", + "scripts": { + "test": "jest" + }, + "devDependencies": { + "jest": "^29.7.0" + } +} diff --git a/YLErpWeb/fe-tests/parity.test.js b/YLErpWeb/fe-tests/parity.test.js new file mode 100644 index 00000000..a9ba5053 --- /dev/null +++ b/YLErpWeb/fe-tests/parity.test.js @@ -0,0 +1,96 @@ +/** + * parity.test.js — 生产代码 vs SwapCalc 等价性证明(替换前的"零风险闸门") + * ============================================================================ + * 方法:把 incomeSwapTrade.js / swapTradeEdit.js 里 4 个公式的【生产表达式逐字】 + * 抄成 PROD_* 函数,与 SwapCalc.* 在 FC 场景 + 随机 + 精度边界上对比。 + * 只有本文件 100% 绿灯,才允许把生产内联表达式替换为 SwapCalc.* 调用。 + * 本文件只读对比,不改动任何生产代码。 + */ +const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js'); + +// ---- 生产表达式逐字抄录(来源见注释行号) ---- +// incomeSwapTrade.js L76 +function PROD_getPriceScale(multiplier) { return multiplier == 100 ? 0.01 : 1; } +// incomeSwapTrade.js L87(仅此一处是初值推导,L156/L268 是别的逻辑,不在此对比) +function PROD_deriveTradingAmountAvg(initPosiGrossPrice, multiplier) { return initPosiGrossPrice * multiplier; } +// incomeSwapTrade.js L179:4 个加数均为 2 位小数,.toFixed(2) 与 round-to-2 等价 +function PROD_calcFloatPnlSum(markClosePnl, TradingFee, TradingFeePending, DividendIn) { + return (parseFloat(markClosePnl) + TradingFee + TradingFeePending + DividendIn).toFixed(2); +} +// swapTradeEdit.js L360:lodash _.round(x,2) === Math.round(x*100)/100 +function lodashRound(x, d) { var f = Math.pow(10, d); return Math.round(x * f) / f; } +function PROD_calcStockEqvNotional(price, national) { return lodashRound(price * national, 2); } + +// 随机 2 位小数金额(模拟真实环境:所有加数都已是 2 位小数) +function rand2() { return Math.round(Math.random() * 2000000) / 100; } // 0.00 ~ 20000.00 + +describe('parity: getPriceScale (incomeSwapTrade.js L76)', () => { + const cases = [100, 1, 1000, 0, 200, 10]; + cases.forEach((m) => { + test('multiplier=' + m, () => { + expect(PROD_getPriceScale(m)).toBe(SwapCalc.getPriceScale(m)); + }); + }); +}); + +describe('parity: deriveTradingAmountAvg (incomeSwapTrade.js L87)', () => { + // FC 场景的 (grossPrice, multiplier) + const cases = [ + [1.02, 100], [100, 1], [1.02, 100], [95.5, 100], [102.34, 100], [50, 1], + ]; + cases.forEach(([p, m]) => { + test('gross=' + p + ' mult=' + m, () => { + expect(PROD_deriveTradingAmountAvg(p, m)).toBe(SwapCalc.deriveTradingAmountAvg(p, m)); + }); + }); +}); + +describe('parity: calcStockEqvNotional (swapTradeEdit.js L360)', () => { + const cases = [ + [1.02, 10000], [100, 1000], [1.0235, 5000], [99.99, 100], [0.5, 200], + ]; + cases.forEach(([p, n]) => { + test('price=' + p + ' national=' + n, () => { + expect(PROD_calcStockEqvNotional(p, n)).toBe(SwapCalc.calcStockEqvNotional(p, n)); + }); + }); + test('随机 200 组 2 位小数输入', () => { + for (let i = 0; i < 200; i++) { + const p = rand2(), n = Math.round(Math.random() * 100000); + expect(PROD_calcStockEqvNotional(p, n)).toBe(SwapCalc.calcStockEqvNotional(p, n)); + } + }); +}); + +describe('parity: calcFloatPnlSum (incomeSwapTrade.js L179) — 真实 2 位小数输入', () => { + // 真实场景:MarkClosePnl 已在 L178 被 otcformat 取整为 2 位;费用/分红也 2 位 + const cases = [ + [30, 20, 0, 0], [100.456, 1, 0.5, 0], [-5000, 0, 0, 0], [300, 100, 50, 0], [450, 100, 50, 100], + [rand2(), rand2(), rand2(), rand2()], [rand2(), rand2(), rand2(), rand2()], + ]; + cases.forEach((c, idx) => { + test('case#' + idx, () => { + const prod = parseFloat(PROD_calcFloatPnlSum(c[0], c[1], c[2], c[3])); + const swap = SwapCalc.calcFloatPnlSum(c[0], c[1], c[2], c[3]); + expect(prod).toBe(swap); // 2 位小数输入下 .toFixed(2) === round-to-2 + }); + }); + test('随机 300 组 2 位小数输入(证明真实路径零差异)', () => { + for (let i = 0; i < 300; i++) { + const a = rand2(), b = rand2(), c = rand2(), d = rand2(); + const prod = parseFloat(PROD_calcFloatPnlSum(a, b, c, d)); + const swap = SwapCalc.calcFloatPnlSum(a, b, c, d); + expect(prod).toBe(swap); + } + }); +}); + +// 最坏输入验证:即便喂入未取整的原始值(如 1.005),toFixed 与 round-half-away 在浮点现实下 +// 同样得到 1.00,证明不存在舍入接缝。生产环境 4 个加数恒为 2 位小数,更不可能分歧。 +describe('parity: 最坏输入也无舍入接缝', () => { + test('原始 1.005 输入下两者仍一致', () => { + const prod = parseFloat(PROD_calcFloatPnlSum(1.005, 0, 0, 0)); // "1.00" + const swap = SwapCalc.calcFloatPnlSum(1.005, 0, 0, 0); // 1.00 + expect(prod).toBe(swap); + }); +}); diff --git a/YLErpWeb/fe-tests/swapCalc.test.js b/YLErpWeb/fe-tests/swapCalc.test.js new file mode 100644 index 00000000..7d3dca9b --- /dev/null +++ b/YLErpWeb/fe-tests/swapCalc.test.js @@ -0,0 +1,136 @@ +/** + * 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); + }); + + // 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); + }); +}); + +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, + closeNotionalValue: 10000, 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, + closeNotionalValue: 10000, 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, + closeNotionalValue: 10000, 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'); + }); +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js index 09bcf96b..a9681684 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js @@ -73,7 +73,7 @@ const vue = new Vue({ return false; }, getPriceScale() { - return this.multiplier == 100 ? 0.01 : 1; + return SwapCalc.getPriceScale(this.multiplier); }, initDeal() { var positions = model.FlowEvents.filter((item) => { @@ -84,7 +84,7 @@ const vue = new Vue({ this.initPosiGrossPrice = this.floatPosition.PosiGrossPrice; // 互换标的价格固定为期初净价,与平仓不同不需要用户填写 // 期初净价入库为相对价(如1.02),需转换为界面百分比形态(102),与平仓页保持一致 - this.floatPosition.TradingAmountAvg = this.initPosiGrossPrice * this.multiplier; + this.floatPosition.TradingAmountAvg = SwapCalc.deriveTradingAmountAvg(this.initPosiGrossPrice, this.multiplier); this.interestList = model.FlowEvents.filter((item) => { return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9; }); @@ -176,7 +176,7 @@ const vue = new Vue({ //thisObj.floatPosition.MarkClosePnl = thisObj.deal.CloseNotionalValue * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiNetPrice) * floatRatio; thisObj.floatPosition.MarkClosePnl = thisObj.deal.CloseNotionalValue * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiGrossPrice) * floatRatio; thisObj.floatPosition.MarkClosePnl = otcformat.trading.StockEqvNotional(thisObj.floatPosition.MarkClosePnl);//MarkClosePnl 纯盯市不要计算交易费用和分红 - thisObj.floatPosition.FloatPnlSum = (parseFloat(thisObj.floatPosition.MarkClosePnl) + TradingFee + TradingFeePending + DividendIn).toFixed(2); + thisObj.floatPosition.FloatPnlSum = SwapCalc.calcFloatPnlSum(thisObj.floatPosition.MarkClosePnl, TradingFee, TradingFeePending, DividendIn).toFixed(2); thisObj.calcCloseAmount(); }, //calcClosePnL() {//计算浮动端平仓盈亏 diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js new file mode 100644 index 00000000..35670d9b --- /dev/null +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -0,0 +1,161 @@ +/** + * swapCalc.js — 互换结算/平仓纯计算函数(与 C# FrontendCalcReference 对齐) + * ============================================================================ + * 设计要点: + * - 无 Vue / otcformat / jQuery / lodash 依赖,全部为纯函数,便于 jest 直接 import。 + * - 浏览器:挂到 window.SwapCalc(需在 incomeSwapTrade.js / swapTradeEdit.js 之前加载)。 + * - Node: module.exports(UMD 包装),供 fe-tests/*.test.js 使用。 + * - 公式与 YLErpDAL/Helpers/FrontendCalcReference.cs 保持一致,是前后端同一份金标准。 + * + * 守卫的 bug(见 git 历史): + * - 20ea93d8 / dcf649f2:deriveTradingAmountAvg 必须用 PosiGrossPrice(全价) 且债券 ×100 + * - 3c5f25a5:calcFloatPnlSum 必须 .toFixed(2)(保留 2 位小数) + * - f873239a:calcStockEqvNotional 必须 round 到 2 位 + * ============================================================================ + */ +(function (root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(); + } else { + root.SwapCalc = factory(); + } +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + // 四舍五入(远离零),对齐 C# MidpointRounding.AwayFromZero + function roundHalfAwayFromZero(value, digits) { + var f = Math.pow(10, digits); + var n = Number(value) * f; + var sign = n < 0 ? -1 : 1; + var r = Math.round(Math.abs(n)) * sign; + var result = r / f; + return result === 0 ? 0 : result; // 消除 -0 + } + + // 价格缩放因子:债券(multiplier=100)界面为百分比态,计算用相对价需 ÷100 + function getPriceScale(multiplier) { + return multiplier === 100 ? 0.01 : 1; + } + + // 期末全价(界面态) = 期初全价(相对价) × multiplier + // 必须用 PosiGrossPrice(全价),非 PosiNetPrice(净价);债券 ×100 转界面百分比态 + function deriveTradingAmountAvg(posiGrossPrice, multiplier) { + return posiGrossPrice * multiplier; + } + + // 金额四舍五入到指定小数位(避免 0.1+0.2 类浮点误差) + function roundMoney(value, digits) { + return roundHalfAwayFromZero(value, digits); + } + + // 浮动盈亏合计 = (平仓盈亏 + 交易费用 + 待结算费用 + 分红).toFixed(2) + function calcFloatPnlSum(markClosePnl, tradingFee, tradingFeePending, dividendIn) { + var sum = (+markClosePnl) + (+tradingFee) + (+tradingFeePending) + (+dividendIn); + return roundHalfAwayFromZero(sum, 2); + } + + // 名义本金 = 期初全价 × 因子,保留 2 位(EQD-6090) + // factor 在前端 = 数量 × 乘数(national) + function calcStockEqvNotional(posiGrossPrice, factor) { + return roundHalfAwayFromZero(posiGrossPrice * factor, 2); + } + + // 盯市平仓盈亏(unwind):CloseQty × (期末全价×scale − 期初全价) × floatRatio × longRatio + // 对齐 FrontendCalcReference.CalcUnwind:先 ×10000 取整再 ÷10000,最后 toFixed(2) + // 干净输入下等价于直接 round(.., 2) + function calcMarkClosePnl(closeQty, tradingAmountAvg, scale, entryPrice, floatRatio, longRatio) { + var product = closeQty * (tradingAmountAvg * scale - entryPrice) * floatRatio * longRatio; + var step = Math.round(product * 10000) / 10000; // 对齐 C# Math.Round(.. * 10000) / 10000 + return roundHalfAwayFromZero(step, 2); + } + + // ---- 组合函数:对齐 C# CalcUnwind / CalcIncome,作为前端与后端金标准的交叉校验 ---- + + function parseOrZero(s) { + return (s === undefined || s === null || s === '') ? 0 : Number(s); + } + + function sumLegs(legs) { + return (legs || []).reduce(function (acc, l) { return acc + parseOrZero(l.interestClosePnL); }, 0); + } + + // 平仓页(unwind)盈亏汇总 — 对齐 FrontendCalcReference.CalcUnwind + function calcUnwind(input) { + var entryPrice = input.posiGrossPrice; + var scale = input.multiplier === 100 ? 0.01 : 1; + var floatRatio = input.payDirection === 1 ? 1 : -1; + var longRatio = input.positionType === 1 ? 1 : -1; + + var tradingFee = parseOrZero(input.tradingFee); + var tradingFeePending = parseOrZero(input.tradingFeePending); + var dividendIn = parseOrZero(input.dividendIn); + + var markClosePnl = calcMarkClosePnl( + input.closeQty, input.tradingAmountAvg, scale, entryPrice, floatRatio, longRatio); + markClosePnl = roundHalfAwayFromZero(markClosePnl, 2); + + var floatPnlSum = roundHalfAwayFromZero(markClosePnl + tradingFee + tradingFeePending + dividendIn, 2); + + var swapRealizedPnL = floatPnlSum + sumLegs(input.interestLegs) + sumLegs(input.marginLegs); + var swapCloseAmount = floatPnlSum + sumLegs(input.interestLegs) + sumLegs(input.marginLegs); + var swapMarginRebatePnl = sumLegs(input.marginLegs); + + var ratio = input.positionType === 1 ? 1 : -1; + var tradingAmountFeeAvg = input.closeQty === 0 ? 0 + : input.tradingAmountAvg * scale + (tradingFee / input.closeQty) * ratio; + + return { + MarkClosePnl: roundHalfAwayFromZero(markClosePnl, 2), + FloatPnlSum: floatPnlSum, + SwapRealizedPnL: roundHalfAwayFromZero(swapRealizedPnL, 2), + SwapCloseAmount: roundHalfAwayFromZero(swapCloseAmount, 2), + SwapMarginRebatePnl: roundHalfAwayFromZero(swapMarginRebatePnl, 2), + TradingAmountFeeAvg: tradingAmountFeeAvg + }; + } + + // 结息页(income)盈亏汇总 — 对齐 FrontendCalcReference.CalcIncome + function calcIncome(input) { + var entryPrice = input.posiGrossPrice; + var scale = input.multiplier === 100 ? 0.01 : 1; + var floatRatio = input.payDirection === 1 ? 1 : -1; + + var tradingFee = parseOrZero(input.tradingFee); + var tradingFeePending = parseOrZero(input.tradingFeePending); + var dividendIn = parseOrZero(input.dividendIn); + + var markClosePnl = roundHalfAwayFromZero( + input.closeNotionalValue * (input.tradingAmountAvg * scale - entryPrice) * floatRatio, 2); + + var floatPnlSum = roundHalfAwayFromZero(markClosePnl + tradingFee + tradingFeePending + dividendIn, 2); + + var swapRealizedPnL = floatPnlSum + sumLegs(input.interestLegs) + sumLegs(input.marginLegs); + var swapCloseAmount = floatPnlSum + sumLegs(input.interestLegs) + sumLegs(input.marginLegs); + var swapMarginRebatePnl = sumLegs(input.marginLegs); + + var tradingAmountFeeAvg = input.closeQty > 0 + ? input.tradingAmountAvg * scale + (tradingFee / input.closeQty) * floatRatio + : input.tradingAmountAvg * scale; + + return { + MarkClosePnl: markClosePnl, + FloatPnlSum: floatPnlSum, + SwapRealizedPnL: roundHalfAwayFromZero(swapRealizedPnL, 2), + SwapCloseAmount: roundHalfAwayFromZero(swapCloseAmount, 2), + SwapMarginRebatePnl: roundHalfAwayFromZero(swapMarginRebatePnl, 2), + TradingAmountFeeAvg: tradingAmountFeeAvg + }; + } + + return { + roundHalfAwayFromZero: roundHalfAwayFromZero, + getPriceScale: getPriceScale, + deriveTradingAmountAvg: deriveTradingAmountAvg, + roundMoney: roundMoney, + calcFloatPnlSum: calcFloatPnlSum, + calcStockEqvNotional: calcStockEqvNotional, + calcMarkClosePnl: calcMarkClosePnl, + calcUnwind: calcUnwind, + calcIncome: calcIncome + }; +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index 0b3f630b..e19f89d1 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -357,7 +357,7 @@ const vue = new Vue({ this.getSpotPrice(payItem.UnderlyingCode, this.trade.StartDate, payItem); } var national = payItem.PosiQuantity * payItem.ContractSize; - var stockEqvNotional = _.round(payItem.PosiGrossPrice * national, 2);//名义本金=期初价格*数量*乘数 + var stockEqvNotional = SwapCalc.calcStockEqvNotional(payItem.PosiGrossPrice, national);//名义本金=期初价格*数量*乘数 this.trade.StockEqvNotional = otcformat.trading.stockEqvNotional(stockEqvNotional); payItem.PosiNotionalValue = this.trade.StockEqvNotional; }