307 lines
17 KiB
JavaScript
307 lines
17 KiB
JavaScript
/**
|
||
* 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
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 债券净价/全价/收益率三字段互算:回写时跳过"用户手填过的字段"(逐字段手动锁定,永不覆盖)。
|
||
* - manualSet: { CP:bool, DP:bool, YD:bool },标记哪些字段是用户本次会话中手动输入/修改过的
|
||
* - calc: { cleanPrice, dirtyPrice, ytm } 来自 /Bond/CalcBond 的 resp.obj
|
||
* - state: 持有三个字段的对象(直接原地写回)
|
||
* 设计(回应"计算结果不认可时如何优雅手动覆盖"):
|
||
* 用户每手填一个字段,该字段即被锁定;反算只填充"未手填"的字段,已手填的(含刚编辑的)一律不回写。
|
||
* 因此用户可逐个手填全部三个,互算绝不会冲掉其中任何一个。
|
||
* 纯函数,jest 可直接测(见 fe-tests/bondCalc.test.js)。
|
||
*/
|
||
function applyBondCalcResult(state, calc, manualSet) {
|
||
var EPS = 1e-4;
|
||
function write(field, type, value) {
|
||
if (manualSet && manualSet[type]) return; // 用户手填过的字段:绝不回写
|
||
if (value === undefined || value === null) return; // 计算器未返回该值则不覆盖
|
||
var cur = state[field];
|
||
if (typeof cur === 'number' && Math.abs(cur - value) < EPS) return; // 无变化不写,避免光标跳动
|
||
state[field] = value;
|
||
}
|
||
write('cleanPrice', 'CP', calc && calc.cleanPrice);
|
||
write('dirtyPrice', 'DP', calc && calc.dirtyPrice);
|
||
write('ytm', 'YD', calc && calc.ytm);
|
||
}
|
||
|
||
/**
|
||
* 债券价格【存储态小数 ↔ 展示态每百元百分比】边界换算。
|
||
* 约定(见 ConsGlobal.bondPriceMultiple=0.01 / bondShowPriceMultiple=100、BondPriceConverter):
|
||
* - 模型/DB 存【存储态小数】(如 0.995 = 99.5 元/百元面值);
|
||
* - 债券计算器(bond-calc,经 zszq-bond-oms 代理)要【展示态百分比】(如 99.5)。
|
||
* 历史坑:calcBondForItem 曾漏掉这正反两步,把存储态 0.995 当 99.5 发给计算器、
|
||
* 又把返回 97.82 原样落库;配合 vue-number-input 的 percent:true(显示再×100)
|
||
* 造成 -117.93 / 378543 离谱值。故发计算器前 bondPriceToCalc(×100)、回写前 bondCalcPriceToStorage(÷100)。
|
||
* 纯函数,jest 可直接测。
|
||
*/
|
||
function bondPriceToCalc(storagePrice) {
|
||
return Number(storagePrice) * 100; // 存储态小数 → 展示态每百元百分比
|
||
}
|
||
function bondCalcPriceToStorage(displayPrice) {
|
||
return Number(displayPrice) / 100; // 展示态百分比 → 存储态小数
|
||
}
|
||
|
||
/**
|
||
* 从 /Bond/CalcBond 响应(resp.obj,即 CalBondResult)中提取应展示给用户的错误文案;
|
||
* 无错误返回 null(调用方据此决定是否回写、是否提示)。
|
||
* 覆盖:
|
||
* - 空响应(计算器无响应)
|
||
* - 业务错误码 errCode!=0(债券不存在 / 债券信息不全 / 参数非法)
|
||
* - 防御性值域校验(errCode=0 但数值离谱):即便计算器返回 success,
|
||
* 仍可能因【部署环境债券主数据量纲错误(如票息/应计被存成百分数×100)】或
|
||
* 行权收益率哨兵(-999999) 而给出负净价 / 收益率量级爆炸(如 378543) 之类的垃圾值。
|
||
* 现有 errCode 守卫拦不住这类"成功但离谱"的响应,故在此加值域闸门,
|
||
* 宁可不回写并提示用户核对主数据,也绝不用垃圾值覆盖手工输入。
|
||
* 与 C# BondCalcHepler 的 errCode 守卫一一对应,确保"坏结果"不会静默回写覆盖手工输入。
|
||
* 纯函数,jest 可直接测(见 fe-tests/bondCalc.test.js)。
|
||
*/
|
||
function getBondCalcErrorMessage(resp) {
|
||
if (!resp) return "债券计算器无响应,已保留手工输入";
|
||
if (resp.errCode && resp.errCode !== 0) {
|
||
return resp.errMsg || "债券计算失败,请检查标的或参数";
|
||
}
|
||
// 防御性值域校验:拦截 errCode=0 但数值离谱的响应(部署环境主数据量纲错误 / 哨兵值)
|
||
var SENTINEL = -999999; // bond-calc 行权收益率不可用哨兵
|
||
var cp = resp.cleanPrice, dp = resp.dirtyPrice, yd = resp.ytm;
|
||
var absurd = function (v) {
|
||
return typeof v === 'number' && (v === SENTINEL || v === SENTINEL * 100);
|
||
};
|
||
if (absurd(cp) || absurd(dp) || absurd(yd)) {
|
||
return "债券计算返回哨兵值(部分指标不可用),已保留手工输入,请核对债券主数据";
|
||
}
|
||
// 净价/全价:占面值百分比,正常约 20~300,绝不为负、也不会破千
|
||
if ((typeof cp === 'number' && (cp <= 0 || cp > 1000)) ||
|
||
(typeof dp === 'number' && (dp <= 0 || dp > 1000))) {
|
||
return "债券计算净价/全价超出合理范围(应为面值百分比且为正),已保留手工输入;" +
|
||
"请检查估值日/价格输入或联系管理员核对债券计算服务";
|
||
}
|
||
// 到期收益率:百分数口径(如 6.37 表示 6.37%),正常约 -5~30,|收益率|>100 视为爆炸
|
||
if (typeof yd === 'number' && Math.abs(yd) > 100) {
|
||
return "债券计算收益率量级异常(" + yd + "),已保留手工输入;" +
|
||
"请检查估值日/价格输入或联系管理员核对债券计算服务";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
applyBondCalcResult: applyBondCalcResult,
|
||
getBondCalcErrorMessage: getBondCalcErrorMessage,
|
||
bondPriceToCalc: bondPriceToCalc,
|
||
bondCalcPriceToStorage: bondCalcPriceToStorage,
|
||
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
|
||
};
|
||
});
|