Files
zszq-trs/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js
T

307 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 保持一致,是前后端同一份金标准。
*
* 生产接线状态(tested == used):incomeSwapTrade.js / swapTradeEdit.js 已调用
* getPriceScale / deriveTradingAmountAvg / calcFloatPnlSum / calcStockEqvNotional
* 这 4 个叶子函数(对应真实出过的 4 个 bug20ea93d8 / dcf649f2 / 3c5f25a5 / f873239a)。
* calcUnwind / calcIncome 仅用于 swapCalc.test.js 的前后端金标准交叉校验,未接入生产代码。
*
* 守卫的 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;
}
// 普通打开时以期初全价作为期末价默认值;审批打开时回显已提交的期末相对价。
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
};
});