/** * 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 保持一致,是前后端同一份金标准。 * * 生产接线状态(tested == used):incomeSwapTrade.js / swapTradeEdit.js 已调用 * getPriceScale / deriveTradingAmountAvg / calcFloatPnlSum / calcStockEqvNotional * 这 4 个叶子函数(对应真实出过的 4 个 bug:20ea93d8 / dcf649f2 / 3c5f25a5 / f873239a)。 * calcUnwind / calcIncome 仅用于 swapCalc.test.js 的前后端金标准交叉校验,未接入生产代码。 * * 守卫的 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; } // 普通打开时以期初全价作为期末价默认值;审批打开时回显已提交的期末相对价。 function resolveIncomeTradingAmountAvg(posiGrossPrice, submittedTradingAmountAvg, multiplier, isUseApproval) { var relativePrice = isUseApproval && submittedTradingAmountAvg !== undefined && submittedTradingAmountAvg !== null ? submittedTradingAmountAvg : posiGrossPrice; return relativePrice * 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); } // 平仓名义本金 = 平仓比例 × 剩余持仓名义本金(PosiNotionalValue) // 多次部分平仓后必须用剩余本金 PosiNotionalValue,不能用原始 NotionalValue,否则偏大 function calcCloseNotionalByRemaining(closePercent, posiNotionalValue) { return roundHalfAwayFromZero(Number(closePercent) * Number(posiNotionalValue), 2); } // 平仓比例 = 平仓名义本金 / 剩余持仓名义本金(PosiNotionalValue) // 多次部分平仓后必须以剩余本金为分母,否则比例偏小,导致后端预付金返还本金计算错误 function calcClosePercentByRemaining(closeNotionalValue, posiNotionalValue) { if (Number(posiNotionalValue) === 0) return 0; return roundHalfAwayFromZero(Number(closeNotionalValue) / Number(posiNotionalValue), 6); } // 平仓数量(占期初口径 A):CloseQty = PositionQty × (closePercent / oriClosePercent) // closePercent 是"占期初名义本金比例"(A),需先除以 oriClosePercent(=剩余/期初) 转成"占剩余比例"(B), // 再乘以剩余持仓数量 PositionQty。 // 多次部分平仓后必须这样转换,否则全部↔部分切换时 ClosePercent 没变但 CloseQty 会变(不自洽)。 // 除零保护:oriClosePercent=0(剩余为0,已全部平完)时返回 0。 function calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, positionQty) { var ori = Number(oriClosePercent); if (ori === 0) return 0; return roundHalfAwayFromZero(Number(positionQty) * (Number(closePercent) / ori), 2); } // 平仓比例(占期初口径 A)= (CloseQty / PositionQty) × oriClosePercent // CloseQty/PositionQty 得到"占剩余比例"(B),乘以 oriClosePercent(=剩余/期初) 转成"占期初比例"(A)。 // 除零保护:PositionQty=0 时返回 0。 function calcOriginalClosePercentByQty(closeQty, positionQty, oriClosePercent) { var qty = Number(positionQty); if (qty === 0) return 0; return roundHalfAwayFromZero((Number(closeQty) / qty) * Number(oriClosePercent), 6); } // 盯市平仓盈亏(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# FrontendCalcReference.CalcUnwind / CalcIncome ---- // 用途:作为「前端 JS 完整盈亏聚合公式」与「后端 C# 金标准」的交叉校验 // (见 swapCalc.test.js 的 FC_001~FC_008 八个冻结场景)。 // 注意:以下 calcUnwind / calcIncome **未接入生产代码**——生产 Vue 组件只调用上方 // 4 个叶子函数。它们是冻结完整聚合逻辑的参考规格;若要让生产聚合逻辑也被自动守卫, // 需把 incomeSwapTrade.js / swapTradeEdit.js / unwindSwapTrade.js 的聚合计算也改调它们。 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 contractSize = input.contractSize === undefined || input.contractSize === null ? 1 : Number(input.contractSize); var markClosePnl = roundHalfAwayFromZero( input.positionQty * contractSize * (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, resolveIncomeTradingAmountAvg: resolveIncomeTradingAmountAvg, roundMoney: roundMoney, calcFloatPnlSum: calcFloatPnlSum, calcStockEqvNotional: calcStockEqvNotional, calcCloseNotionalByRemaining: calcCloseNotionalByRemaining, calcClosePercentByRemaining: calcClosePercentByRemaining, calcCloseQtyByOriginalPercent: calcCloseQtyByOriginalPercent, calcOriginalClosePercentByQty: calcOriginalClosePercentByQty, calcMarkClosePnl: calcMarkClosePnl, calcUnwind: calcUnwind, calcIncome: calcIncome }; });