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:
hjhan
2026-07-07 12:30:19 +08:00
parent ddac1d8e11
commit 7f64d48699
10 changed files with 511 additions and 4 deletions
@@ -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.exportsUMD 包装),供 fe-tests/*.test.js 使用。
* - 公式与 YLErpDAL/Helpers/FrontendCalcReference.cs 保持一致,是前后端同一份金标准。
*
* 守卫的 bug(见 git 历史):
* - 20ea93d8 / dcf649f2deriveTradingAmountAvg 必须用 PosiGrossPrice(全价) 且债券 ×100
* - 3c5f25a5calcFloatPnlSum 必须 .toFixed(2)(保留 2 位小数)
* - f873239acalcStockEqvNotional 必须 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;
}