test(fe): 接入 SwapCalc 纯计算模块 + 前端精度回归测试

- incomeSwapTrade.js / swapTradeEdit.js 改为调用 SwapCalc.*(缩放/取整/全价推导)

- 新增 fe-tests 守卫:4 个历史 bug 回归 + 8 场景对齐 C# FrontendCalcReference 金标准 + otcformat.js 精度配置守卫

- 新增 .git/hooks/pre-commit 自动运行前端守卫(失败阻断提交)
This commit is contained in:
hjhan
2026-07-07 12:30:19 +08:00
parent ddac1d8e11
commit 7f64d48699
10 changed files with 511 additions and 4 deletions
+50
View File
@@ -0,0 +1,50 @@
/**
* _shim_run.js — 不依赖 jest 的轻量测试运行器(仅用于在沙箱内验证测试文件逻辑)。
* 本机请用 jestcd 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);
+50
View File
@@ -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);
}
});
});
});
+12
View File
@@ -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"
}
}
+96
View File
@@ -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 L1794 个加数均为 2 位小数,.toFixed(2) 与 round-to-2 等价
function PROD_calcFloatPnlSum(markClosePnl, TradingFee, TradingFeePending, DividendIn) {
return (parseFloat(markClosePnl) + TradingFee + TradingFeePending + DividendIn).toFixed(2);
}
// swapTradeEdit.js L360lodash _.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);
});
});
+136
View File
@@ -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);
});
// 3c5f25a5FloatPnlSum 必须保留 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');
});
});