unwindSwapTrade.js: - oriClosePercent 恒为 1(Definition B 下最多平 100% 剩余,修正全部平仓/按比例少返预付金 + 进页面 getInterestList 少算) - CloseMethod==1(全部平仓)时修正 ClosePercent=1;CloseMethod==2(部分平仓/待复核)保留已提交比例,避免覆盖 - changeCloseQty/Percent/NotionalValue 三处换算改用 PosiNotionalValue(剩余本金),不再用原始 NotionalValue swapCalc.js: 新增 calcCloseNotionalByRemaining / calcClosePercentByRemaining 纯函数(含除零保护) swapCalc.test.js + _shim_run.js: 补充多次部分平仓换算单测(剩余vs原始对比、第3次全平、3次合计=原始、除零保护)
195 lines
9.7 KiB
JavaScript
195 lines
9.7 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// 多次部分平仓:平仓比例 ↔ 平仓名义本金 换算必须以"剩余持仓名义本金"为基准
|
||
// 守卫 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);
|
||
});
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|