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();
});
},
|