债券计算器 返回结果后要进行 /100 * 100 处理 适配目前的 这个 /100 * 100,避免 很离谱的 值
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 防御性值域校验:判断计算器返回值是否"离谱"(errCode=0 但净价变负 / 收益率量级爆炸 / 命中哨兵值)。
|
||||
/// 命中则上层返回 null、不回写,避免把不可信结果覆盖用户手工输入。规则:
|
||||
/// - 净价/全价:占面值百分比,正常约 20~300,必须为正且不破千(≤0 或 >1000 视为离谱);
|
||||
/// - 收益率(ytm,百分数口径):|ytm| > 100 视为爆炸(允许负利率债,但量级须合理);
|
||||
/// - 哨兵值:任一字段命中 -999999 或 -999999×100(如 yieldToCP 不可用哨兵透传)。
|
||||
/// 与前端 swapCalc.js::getBondCalcErrorMessage 的值域闸门保持一致口径。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -452,13 +452,13 @@
|
||||
</a>
|
||||
</td>
|
||||
<td v-if="trade.StructureType!='普通收益互换'">
|
||||
<vue-number-input :key="getPosiPriceFormatKey(item,'PosiGrossPrice')" v-model="item.PosiGrossPrice" v-bind:format="inputFormatMarginRateNoPercent" v-on:input="changeSpotPrice(item)" v-on:change="onBondPriceInput(item,'DP')"></vue-number-input><span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondDriverType==='DP'">源</span><span style="color:#ef6c00;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondManual && item.bondManual.DP">手动</span>
|
||||
<vue-number-input :key="getPosiPriceFormatKey(item,'PosiGrossPrice')" v-model="item.PosiGrossPrice" v-bind:format="inputFormatMarginRateNoPercent" v-on:input="onDpPriceInput(item)"></vue-number-input><span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondDriverType==='DP'">源</span><span style="color:#ef6c00;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondManual && item.bondManual.DP">手动</span>
|
||||
</td>
|
||||
<td v-if="trade.StructureType!='普通收益互换'">
|
||||
<vue-number-input :key="getPosiPriceFormatKey(item,'PosiNetNoFeePrice')" v-model="item.PosiNetNoFeePrice" v-bind:format="inputFormatMarginRateNoPercent" v-on:change="onBondPriceInput(item,'CP')"></vue-number-input><span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondDriverType==='CP'">源</span><span style="color:#ef6c00;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondManual && item.bondManual.CP">手动</span>
|
||||
<vue-number-input :key="getPosiPriceFormatKey(item,'PosiNetNoFeePrice')" v-model="item.PosiNetNoFeePrice" v-bind:format="inputFormatMarginRateNoPercent" v-on:input="onBondPriceInput(item,'CP')"></vue-number-input><span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondDriverType==='CP'">源</span><span style="color:#ef6c00;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondManual && item.bondManual.CP">手动</span>
|
||||
</td>
|
||||
<td v-if="trade.StructureType!='普通收益互换'">
|
||||
<vue-number-input :key="getPosiPriceFormatKey(item,'InitYtm')" v-model="item.InitYtm" v-bind:format="inputFormatMarginRateNoPercent" v-on:change="onBondPriceInput(item,'YD')"></vue-number-input><span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondDriverType==='YD'">源</span><span style="color:#ef6c00;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondManual && item.bondManual.YD">手动</span><a href="javascript:void(0);" v-on:click="resetBondCalc(item)" v-if="item.isBond" style="margin-left:6px;font-size:11px;color:#1565c0;">重算</a>
|
||||
<vue-number-input :key="getPosiPriceFormatKey(item,'InitYtm')" v-model="item.InitYtm" v-bind:format="inputFormatMarginRateNoPercent" v-on:input="onBondPriceInput(item,'YD')"></vue-number-input><span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondDriverType==='YD'">源</span><span style="color:#ef6c00;font-size:11px;margin-left:4px;" v-if="item.isBond && item.bondManual && item.bondManual.YD">手动</span><a href="javascript:void(0);" v-on:click="resetBondCalc(item)" v-if="item.isBond" style="margin-left:6px;font-size:11px;color:#1565c0;">重算</a>
|
||||
</td>
|
||||
<td v-if="trade.StructureType=='普通收益互换'">
|
||||
<vue-number-input :key="getPosiPriceFormatKey(item,'normalPosiGrossPrice')" v-model="item.PosiGrossPrice" v-bind:format="inputFormatTradeSinglePrice" v-on:input="changeSpotPrice(item)"></vue-number-input>
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user