Files
zszq-trs/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js
T
hjhan b9f2bd14f6 feat(swaptrade): 债券三字段计算器交互改为回车触发并加源/AUTO/REV 三态标识
- 约定1:仅在净价/全价/收益率输入框敲回车触发计算器,失焦不触发
- 约定2:计算成功->源字段标"源"、另两字段标"AUTO";失败->保留源值、清空另两字段与标识
- 约定3:编辑某字段未回车->不联动另两字段,清源/AUTO、仅本字段标"REV"
- swapCalc.js 新增 applyBondCalcSuccess/Failure/ManualEdit 纯函数,applyBondCalcResult 第三参由 manualSet 改为 driver(跳过源字段写回)
- fastVue.base.js 新增 enter 事件区分回车/失焦,向后兼容旧的 input 行为
- TradeEdit.cshtml 三字段 input/enter 分离绑定 + 源/AUTO/REV 三态标签(去除旧铅笔图标)
- 重写 bondCalc.test.js / 更新 swapCalc.test.js 覆盖状态机;jest 68 项全过
2026-07-29 14:34:25 +08:00

411 lines
22 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
};
}
/**
* 债券净价/全价/收益率三字段互算:以 driver(CP/DP/YD) 为"源",回写时跳过源字段(保留用户手输值),
* 其余两字段用计算器结果覆盖。
* - driver: 'CP'净价 / 'DP'全价 / 'YD'收益率,即本次回车的输入依据
* - calc: { cleanPrice, dirtyPrice, ytm } 来自 /Bond/CalcBond 的 resp.obj
* - state: 持有三个字段的对象(直接原地写回)
* 设计(对应交互约定1+2):
* 回车某字段即以其为源调计算器,另两个字段被反算覆盖;源字段保持用户手输值不被回写。
* 故用户回车净价→全价/收益率被重算;再次回车全价→净价/收益率被重算(最后回车者恒为源)。
* 纯函数,jest 可直接测(见 fe-tests/bondCalc.test.js)。
*/
function applyBondCalcResult(state, calc, driver) {
var EPS = 1e-4;
function write(field, type, value) {
if (driver === 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; // 展示态百分比 → 存储态小数
}
/**
* 清空债券三字段互算的【源/AUTO/REV】标志(纯函数,jest 可测)。
* 用途:切换标的(setUnderlyingCode)时调用,避免旧债券的状态污染新债券(D1 修复)。
* 直接对传入对象赋值;在 Vue 组件里该 item 已是响应式对象,重赋值能正常触发响应式更新。
*/
function clearBondCalcFlags(state) {
state.bondDriverType = null;
state.bondAuto = { CP: false, DP: false, YD: false };
state.bondRev = { CP: false, DP: false, YD: false };
return state;
}
/**
* 交互约定2(纯函数,jest 可测):计算器调用【成功】后的标识落地。
* - 以 driver(CP/DP/YD) 为"源",标记 bondDriverType=driver
* - 其余两字段标记 bondAuto=true"AUTO"=计算器算出的);
* - 清除 bondRev(人工输入标记)。
* 不写任何价格数值——数值由 applyBondCalcResult 回写。
*/
function applyBondCalcSuccess(state, driver) {
state.bondDriverType = driver;
state.bondAuto = { CP: driver !== 'CP', DP: driver !== 'DP', YD: driver !== 'YD' };
state.bondRev = { CP: false, DP: false, YD: false };
return state;
}
/**
* 交互约定2(纯函数,jest 可测):计算器调用【失败】后的标识与数值落地。
* - 保留用户刚回车的"源"字段值(driver),让用户可就地修改;
* - 另两个字段清空为空白("另外两个数值都更新为空白");
* - 三个数值旁边的标识全部清空("三个数值旁边都清空标识")。
*/
function applyBondCalcFailure(state, driver) {
var fields = { CP: 'PosiNetNoFeePrice', DP: 'PosiGrossPrice', YD: 'InitYtm' };
Object.keys(fields).forEach(function (t) {
if (t !== driver) state[fields[t]] = null; // 非源字段清空
});
state.bondDriverType = null;
state.bondAuto = { CP: false, DP: false, YD: false };
state.bondRev = { CP: false, DP: false, YD: false };
return state;
}
/**
* 交互约定3(纯函数,jest 可测):编辑某字段但未回车(失焦/输入)时的标识落地。
* - 清除 源 与 AUTO 标识(不再代表任何已算结果);
* - 仅本字段标 bondRev[type]=true"REV"=人工输入),不联动另外两字段。
*/
function applyBondManualEdit(state, type) {
state.bondDriverType = null;
state.bondAuto = { CP: false, DP: false, YD: false };
state.bondRev = { CP: false, DP: false, YD: false };
if (type) state.bondRev[type] = true;
return state;
}
/**
* 从 /Bond/CalcBond 响应(resp.obj,即 CalBondResult)中提取应展示给用户的错误文案;
* 无错误返回 null(调用方据此决定是否回写、是否提示)。
* 覆盖:
* - 空响应(计算器无响应)
* - 业务错误码 errCode!=0(债券不存在 / 债券信息不全 / 参数非法)
* - 防御性值域校验(errCode=0 但数值离谱):即便计算器返回 success,
* 仍可能因【前端↔计算器的单位换算不匹配】或
* 行权收益率哨兵(-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;
}
/**
* 加载路径(重开/刷新已保存债券单)调用(纯函数,jest 可测):
* 把三字段互算的状态标识全部清空,使已保存的净价/全价/收益率纯展示、无任何 源/AUTO/REV 标记。
* - 不锁定、不置源:交互约定3规定"编辑未回车不联动",故用户载入后即使改某格(未回车)也不会反算另两格;
* 只有用户主动回车某格时,才以该格为源重新推导(符合交互约定1)。
* 仅在加载路径对"债券且三值齐全"的标的调用,全新未填的债券无副作用。
*/
function clearBondCalcMarksOnLoad(state) {
state.bondDriverType = null;
state.bondAuto = { CP: false, DP: false, YD: false };
state.bondRev = { CP: false, DP: false, YD: false };
return state;
}
/**
* D3 修复(纯函数,jest 可测):债券计算器失败提示去重。
* 不可算的债券上用户逐键手填时,v-on:input 每次按键都触发一次失败计算并弹 toast,会连刷数条相同提示。
* 这里按"同一错误文案连续出现只提示一次"抑制噪声;错误文案变化(如 债券不存在→信息不全)则照常提示,
* 成功(传入 null/空)时清掉标记,便于下次真出不同错误时仍能提示。
* 纯做"是否该弹"的决策并维护 state._lastBondErr,不触碰任何计算逻辑,零风险。
*/
function shouldShowBondErr(state, err) {
if (!err) { state._lastBondErr = null; return false; } // 成功/无错误:清标记、不提示
if (state._lastBondErr === err) return false; // 连续相同错误 → 抑制重复 toast
state._lastBondErr = err;
return true;
}
/**
* 估值日(开始日)缺失校验(纯函数,jest 可测)。
* 需求:债券净价/全价/收益率以【互换起始日 StartDate】估值;开始日现已默认即有,
* 故不再"悄悄回退到交易日",而是真的为空时返回错误文案,由上层提示用户
* (并允许其手动填写三项数值,见需求2)。
* 返回 非空字符串=缺失需提示;返回 null=已具备估值日。
*/
function getBondStartDateMissingMsg(startDate) {
if (startDate) return null;
return "请先填写开始日(互换起始日,作为估值日)后再计算债券净价/全价/收益率;" +
"若暂不需要计算,可手动填写净价/全价/收益率三项数值";
}
return {
applyBondCalcResult: applyBondCalcResult,
getBondCalcErrorMessage: getBondCalcErrorMessage,
bondPriceToCalc: bondPriceToCalc,
bondCalcPriceToStorage: bondCalcPriceToStorage,
clearBondCalcFlags: clearBondCalcFlags,
applyBondCalcSuccess: applyBondCalcSuccess,
applyBondCalcFailure: applyBondCalcFailure,
applyBondManualEdit: applyBondManualEdit,
clearBondCalcMarksOnLoad: clearBondCalcMarksOnLoad,
shouldShowBondErr: shouldShowBondErr,
getBondStartDateMissingMsg: getBondStartDateMissingMsg,
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
};
});