Files
zszq-trs/YLErpWeb/fe-tests/swapCalc.test.js
T

294 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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作为默认期末价');
});
// 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);
});
});
// ============================================================================
// 多次部分平仓:平仓比例 ↔ 平仓名义本金 换算必须以"剩余持仓名义本金"为基准
// 守卫 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
// 旧 bugClosePercent 是占期初口径(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 不跳变', () => {
// 旧 bugCloseQty = 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.999999996JS 浮点精度偏差)
// roundHalfAwayFromZero 应正确舍入为 25000000
test('GLMS-20260701-000632500000×(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包含分红');
});
});