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:
@@ -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