Files
zszq-trs/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js
T
hjhan 8694e6e3f4 债券互换三字段互算:估值日(开始日)缺失报错 + 补提漏交回归用例
- 估值日严格取互换起始日 StartDate,去掉"悄悄回退交易日(TradeDate)":
  真的为空时返回错误提示("请先填写开始日…或手动填写三项数值")并 return,
  不调用计算器、不覆盖手工输入,与需求(按开始日估值)完全一致。
- 抽纯函数 getBondStartDateMissingMsg(StartDate 空→文案/null) + 导出;
  复用 D3 的 shouldShowBondErr 去重,避免空开始日时价格格逐键连刷。
- BondCalcHepler.cs 仅补 XML 注释说明前端已强制 StartDate 必填;
  targetDate 空→代理默认 T+1 行为不变(兜底 RealtimePnlCalc 旧链路)。
- 补提此前漏提交的回归用例:bondCalc.test.js(×100/÷100 端到端 + 逐字段手动锁定)、
  swapCalc.test.js(D1/D2/D3 + 本次估值日缺失守卫)。
- jest bondCalc+swapCalc 全绿(68 passed)。
2026-07-23 16:49:40 +08:00

364 lines
20 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; // 展示态百分比 → 存储态小数
}
/**
* 清空债券三字段互算的【手动/源】标志(纯函数,jest 可测)。
* 用途:切换标的(setUnderlyingCode)时调用,避免旧债券的手填状态(bondManual/bondDriverType)
* 污染新债券——否则旧债券标记过的字段在新债券上会被错误跳过 / 沿用旧态(D1 修复)。
* 直接对传入对象赋值;在 Vue 组件里该 item 已是响应式对象(首次交互已 $set 过 bondManual),
* 故重赋值能正常触发响应式更新;全新未交互过的标的清不清都无副作用。
*/
function clearBondCalcFlags(state) {
state.bondManual = { CP: false, DP: false, YD: false };
state.bondDriverType = null;
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;
}
/**
* D2 修复(纯函数,jest 可测):重开(审批重开/刷新)一只【已保存且三字段齐全】的债券成交单时,
* 把三字段互算的手动标志一次性全置 true,使计算器在重开期间不再自动反算、覆盖当初保存的其他两个值。
* - 不设置 bondDriverType:重开时没有任何字段作为"计算源"calcBondForItem 会早返回(要求 driver 非空)
* 故纯展示保存值、零自动推导;用户点"重算"才清除标志并重新推导。
* - 用户若编辑其中某格:onBondPriceInput 会把它设为 driver 并重算,但因另两格仍是 manual=true
* 不会被覆盖 → 满足"重开后手填覆盖跨会话 sticky、编辑不联动另两格"。
* 仅在加载路径对"债券且三值齐全"的标的调用,全新未填的债券不会被误锁。
*/
function markExistingBondManual(state) {
state.bondManual = { CP: true, DP: true, YD: true };
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,
markExistingBondManual: markExistingBondManual,
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
};
});