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:
@@ -28,6 +28,7 @@
|
||||
<script src="~/front/calendar?v=@(HtmlUtil.JsVersion)"></script>
|
||||
<script src="~/Scripts/fast/fastVue.components.js?v=@HtmlUtil.JsVersion"></script>
|
||||
<script src="~/Scripts/app/tradeHelper.js?v=@HtmlUtil.JsVersion"></script>
|
||||
<script src="~/Scripts/app/swaptrade/swapCalc.js?v=@HtmlUtil.JsVersion"></script>
|
||||
<script src="~/Scripts/app/swaptrade/incomeSwapTrade.js?v=@HtmlUtil.JsVersion"></script>
|
||||
}
|
||||
<div class="pb-3" id="vueDiv">
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
<script src="~/front/calendar?v=@(HtmlUtil.JsVersion)"></script>
|
||||
<script src="~/Scripts/fast/fastVue.components.js?v=@HtmlUtil.JsVersion"></script>
|
||||
<script src="~/Scripts/app/tradeHelper.js?v=@HtmlUtil.JsVersion"></script>
|
||||
<script src="~/Scripts/app/swaptrade/swapCalc.js?v=@HtmlUtil.JsVersion"></script>
|
||||
<script src="~/Scripts/app/swaptrade/swapTradeEdit.js?v=@HtmlUtil.JsVersion"></script>
|
||||
}
|
||||
<div id="tradeEdit">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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() {//计算浮动端平仓盈亏
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user