diff --git a/YLErpDAL/Helpers/BondCalcHepler.cs b/YLErpDAL/Helpers/BondCalcHepler.cs index adf92b19..a62b54f1 100644 --- a/YLErpDAL/Helpers/BondCalcHepler.cs +++ b/YLErpDAL/Helpers/BondCalcHepler.cs @@ -88,6 +88,17 @@ namespace YLErp.Helpers LogFactory.GetLogger("BondCalcHepler").Error("债券计算业务错误(code=" + result.data.errCode + "):" + errorMsg); return null; } + // 防御性值域校验:errCode=0 但数值离谱(如净价变负 / 收益率量级爆炸 / 行权收益率哨兵 -999999)。 + // 这类"成功但不可信"的响应现有 errCode 守卫拦不住,此处兜底:宁可返回 null+提示用户核对, + // 也绝不把垃圾值回写覆盖手工输入。 + string absurdReason; + if (IsResultAbsurd(result.data, out absurdReason)) + { + errorMsg = absurdReason; + LogFactory.GetLogger("BondCalcHepler").Error( + $"债券计算返回离谱值(bondId={underlyingCode}):{absurdReason} | cleanPrice={result.data.cleanPrice} dirtyPrice={result.data.dirtyPrice} ytm={result.data.ytm}"); + return null; + } LogFactory.GetLogger("BondCalcHepler").Info( $"债券计算器成功: bondId={underlyingCode} targetDate={targetDate} cleanPrice={result.data.cleanPrice} dirtyPrice={result.data.dirtyPrice} ytm={result.data.ytm}"); return result.data; @@ -127,6 +138,14 @@ namespace YLErp.Helpers LogFactory.GetLogger("BondCalcHelper").Error("债券计算失败:" + msg); return null; } + // 防御性值域校验(同 BondCalc):拦截"成功但数值离谱"的响应,不回写坏数据 + string absurdReason; + if (IsResultAbsurd(result.data, out absurdReason)) + { + LogFactory.GetLogger("BondCalcHelper").Error( + $"债券计算返回离谱值(bondId={underlyingCode}):{absurdReason} | cleanPrice={result.data.cleanPrice} dirtyPrice={result.data.dirtyPrice} ytm={result.data.ytm}"); + return null; + } return result.data; } catch (Exception ex) @@ -136,6 +155,38 @@ namespace YLErp.Helpers } return null; } + + /// + /// 防御性值域校验:判断计算器返回值是否"离谱"(errCode=0 但净价变负 / 收益率量级爆炸 / 命中哨兵值)。 + /// 命中则上层返回 null、不回写,避免把不可信结果覆盖用户手工输入。规则: + /// - 净价/全价:占面值百分比,正常约 20~300,必须为正且不破千(≤0 或 >1000 视为离谱); + /// - 收益率(ytm,百分数口径):|ytm| > 100 视为爆炸(允许负利率债,但量级须合理); + /// - 哨兵值:任一字段命中 -999999 或 -999999×100(如 yieldToCP 不可用哨兵透传)。 + /// 与前端 swapCalc.js::getBondCalcErrorMessage 的值域闸门保持一致口径。 + /// + private static bool IsResultAbsurd(CalBondResult data, out string reason) + { + reason = null; + if (data == null) return false; + const decimal SENTINEL = -999999m; + bool Absurd(decimal v) => v == SENTINEL || v == SENTINEL * 100m; + if (Absurd(data.cleanPrice) || Absurd(data.dirtyPrice) || (data.ytm.HasValue && Absurd(data.ytm.Value))) + { + reason = "债券计算返回哨兵值(部分指标不可用),请检查估值日/价格输入或联系管理员核对债券计算服务"; + return true; + } + if (data.cleanPrice <= 0m || data.cleanPrice > 1000m || data.dirtyPrice <= 0m || data.dirtyPrice > 1000m) + { + reason = "债券计算净价/全价超出合理范围(应为正值且接近面值百分比),请检查估值日/价格输入或联系管理员核对债券计算服务"; + return true; + } + if (data.ytm.HasValue && Math.Abs(data.ytm.Value) > 100m) + { + reason = "债券计算收益率量级异常(" + data.ytm.Value + "),请检查估值日/价格输入或联系管理员核对债券计算服务"; + return true; + } + return false; + } } } diff --git a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml index 396d29aa..f34ef1ce 100644 --- a/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml +++ b/YLErpWeb/Views/SwapTrade2/TradeEdit.cshtml @@ -452,13 +452,13 @@ - 手动 + 手动 - 手动 + 手动 - 手动重算 + 手动重算 diff --git a/YLErpWeb/fe-tests/bondCalc.test.js b/YLErpWeb/fe-tests/bondCalc.test.js index c0756e0e..3397cad4 100644 --- a/YLErpWeb/fe-tests/bondCalc.test.js +++ b/YLErpWeb/fe-tests/bondCalc.test.js @@ -111,6 +111,40 @@ describe('错误反馈:getBondCalcErrorMessage(对齐 C# BondCalcHepler 的 expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull(); }); + test('防御值域:成功(errCode=0)但净价为负 → 拦截提示、不回写(用户实测 180205.IB 场景)', () => { + const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: -117.93, ytm: 6.37 }; + const err = SwapCalc.getBondCalcErrorMessage(resp); + expect(err).not.toBeNull(); + expect(err).toContain("净价"); + }); + + test('防御值域:成功但收益率量级爆炸(378543) → 拦截提示、不回写', () => { + const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 97.82, ytm: 378543.526601942 }; + const err = SwapCalc.getBondCalcErrorMessage(resp); + expect(err).not.toBeNull(); + expect(err).toContain("收益率"); + }); + + test('防御值域:净价超过 1000(量纲错误)→ 拦截', () => { + const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 9782, ytm: 6.37 }; + expect(SwapCalc.getBondCalcErrorMessage(resp)).not.toBeNull(); + }); + + test('防御值域:命中哨兵值 -999999 → 拦截', () => { + const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 99, ytm: -999999 }; + expect(SwapCalc.getBondCalcErrorMessage(resp)).not.toBeNull(); + }); + + test('防御值域:合理范围内的正常值(净价97.82/收益率6.37) → 放行返回 null', () => { + const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 97.82, ytm: 6.37 }; + expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull(); + }); + + test('防御值域:负收益率但量级合理(-2.5%) → 放行(允许负利率债券)', () => { + const resp = { errCode: 0, dirtyPrice: 100, cleanPrice: 99, ytm: -2.5 }; + expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull(); + }); + test('端到端:业务错误时不覆盖手工输入(仅提示)', () => { const item = { PosiNetNoFeePrice: 99.5, PosiGrossPrice: 100.0, InitYtm: 2.6, bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js index 568a7c65..d88945cf 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -224,12 +224,34 @@ 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)。 */ @@ -238,12 +260,34 @@ 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, diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index f3ea9a86..2aebcd88 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -252,6 +252,13 @@ const vue = new Vue({ changeClearingAgency() { this.trade.MetaDic["清算机构"] = this.viewState.Extend.ClearingAgency; }, + //全价(DP)列输入:先按标的单价重算名义本金(非债券标的也需要),再对债券触发三字段反算。 + // 组件 vue-number-input 只 $emit('input')(失焦/回车时由底层 numberInput.onchange 触发), + // 故此列绑 v-on:input,一个入口同时覆盖普通标的与债券两种情形。 + onDpPriceInput(item) { + this.changeSpotPrice(item); // 名义本金随全价变化(非债券标的的既有逻辑) + this.onBondPriceInput(item, 'DP'); // 债券:以全价为源反算净价/收益率(非债券内部会早返回) + }, //债券收益互换:编辑某一浮动腿的净价/全价/收益率之一 → 该字段标记为"已手动设过"(锁定), // 并以它为源调计算器反算"尚未手动设过"的另两个字段(已手动设过的一律不回写) onBondPriceInput(item, type) { // type: 'CP'净价 / 'DP'全价 / 'YD'收益率 @@ -266,9 +273,13 @@ const vue = new Vue({ resetBondCalc(item) { if (!item.isBond) return; this.$set(item, 'bondManual', { CP: false, DP: false, YD: false }); - var driver = (item.PosiNetNoFeePrice !== null && item.PosiNetNoFeePrice !== '') ? 'CP' - : (item.PosiGrossPrice !== null && item.PosiGrossPrice !== '') ? 'DP' - : (item.InitYtm !== null && item.InitYtm !== '') ? 'YD' : null; + // 优先沿用用户最后一次编辑锁定的源(bondDriverType),避免"净价残留脏值"时误以净价为源反算出连锁错误; + // 无历史源时再按 净价→全价→收益率 回退挑一个有值的字段。 + var hasVal = function (v) { return v !== null && v !== '' && !isNaN(v); }; + var driver = item.bondDriverType + || (hasVal(item.PosiNetNoFeePrice) ? 'CP' + : hasVal(item.PosiGrossPrice) ? 'DP' + : hasVal(item.InitYtm) ? 'YD' : null); this.$set(item, 'bondDriverType', driver); this.calcBondForItem(item); }, @@ -295,7 +306,10 @@ const vue = new Vue({ : null; main.post('/Bond/CalcBond', { underlyingCode: item.UnderlyingCode, - price: price, + // 模型 item.PosiGrossPrice/PosiNetNoFeePrice/InitYtm 存的是【存储态小数】(如 0.995), + // 而 bond-calc 要的是【展示态/每百元百分比】(如 99.5)。这俩靠 BondPriceConverter.ToDisplay(×100)/ToStorage(÷100) 对齐。 + // 此处传给计算器前必须 ×100(存储→展示);回写时再 ÷100(展示→存储),否则计算器拿到 0.995≈1 当 1% of par 直接发散成离谱值。 + price: price * 100, priceType: priceType, targetDate: targetDate }, { alertFn: main.message }).done(function (resp) { @@ -311,9 +325,15 @@ const vue = new Vue({ }; var manual = item.bondManual || { CP: false, DP: false, YD: false }; SwapCalc.applyBondCalcResult(proxy, resp.obj, manual); - item.PosiNetNoFeePrice = proxy.cleanPrice; - item.PosiGrossPrice = proxy.dirtyPrice; - item.InitYtm = proxy.ytm; + // 回写前 ÷100(展示态→存储态小数):proxy 里的 cleanPrice/dirtyPrice/ytm 均为【展示态】 + // (bond-calc 返回 per-100-face、代理已把 ytm×100);模型字段存【存储态】(0.995),须 ÷100 落回模型, + // 否则模型值量级错(如 97.82 当 0.9782 存,配合 percent:true 显示又 ×100 成 9782)。 + item.PosiNetNoFeePrice = proxy.cleanPrice / 100; + item.PosiGrossPrice = proxy.dirtyPrice / 100; + item.InitYtm = proxy.ytm / 100; + // 名义本金依赖全价(PosiGrossPrice):以净价/收益率为源反算出的全价被回写后, + // 直接赋值不会触发组件 input 事件,需在此显式重算,保持名义本金与全价一致。 + if (self.calcNotional) self.calcNotional(); if (self.$forceUpdate) self.$forceUpdate(); }); },