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:
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user