diff --git a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml
index 80e6ce7a..b02667f9 100644
--- a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml
+++ b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml
@@ -28,6 +28,7 @@
+
}
diff --git a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml
index 3e8660bc..b8c7d03f 100644
--- a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml
+++ b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml
@@ -96,6 +96,7 @@
+
}
diff --git a/YLErpWeb/fe-tests/_shim_run.js b/YLErpWeb/fe-tests/_shim_run.js
new file mode 100644
index 00000000..5f4cfbb9
--- /dev/null
+++ b/YLErpWeb/fe-tests/_shim_run.js
@@ -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);
diff --git a/YLErpWeb/fe-tests/otcformat.test.js b/YLErpWeb/fe-tests/otcformat.test.js
new file mode 100644
index 00000000..03604a40
--- /dev/null
+++ b/YLErpWeb/fe-tests/otcformat.test.js
@@ -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);
+ }
+ });
+ });
+});
diff --git a/YLErpWeb/fe-tests/package.json b/YLErpWeb/fe-tests/package.json
new file mode 100644
index 00000000..63ecee9a
--- /dev/null
+++ b/YLErpWeb/fe-tests/package.json
@@ -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"
+ }
+}
diff --git a/YLErpWeb/fe-tests/parity.test.js b/YLErpWeb/fe-tests/parity.test.js
new file mode 100644
index 00000000..a9ba5053
--- /dev/null
+++ b/YLErpWeb/fe-tests/parity.test.js
@@ -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);
+ });
+});
diff --git a/YLErpWeb/fe-tests/swapCalc.test.js b/YLErpWeb/fe-tests/swapCalc.test.js
new file mode 100644
index 00000000..7d3dca9b
--- /dev/null
+++ b/YLErpWeb/fe-tests/swapCalc.test.js
@@ -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');
+ });
+});
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
index 09bcf96b..a9681684 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
@@ -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() {//计算浮动端平仓盈亏
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js
new file mode 100644
index 00000000..35670d9b
--- /dev/null
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js
@@ -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
+ };
+});
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js
index 0b3f630b..e19f89d1 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js
@@ -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;
}