diff --git a/YLErpWeb/fe-tests/bondCalc.test.js b/YLErpWeb/fe-tests/bondCalc.test.js index 3397cad4..8b9efc73 100644 --- a/YLErpWeb/fe-tests/bondCalc.test.js +++ b/YLErpWeb/fe-tests/bondCalc.test.js @@ -66,23 +66,66 @@ describe('边界:计算器未返回的值不覆盖、无变化不写', () => { expect(state.ytm).toBe(2.5); }); - // 模拟 calcBondForItem:以既有三字段为代理调纯函数,再写回 item - test('浮动腿 item 写回:仅未手填字段被覆盖', () => { - const item = { PosiNetNoFeePrice: 99.5, PosiGrossPrice: 100.0, InitYtm: 2.6, + // 模拟 calcBondForItem:proxy 统一用【展示态】,模型字段是【存储态】,回写时 bondCalcPriceToStorage。 + test('浮动腿 item 写回:仅未手填字段被覆盖,且存储态转换正确', () => { + // 用户输入净价 99.5 → vue-number-input(percent:true) 存为存储态 0.995 + const item = { PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026, bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; - const calc = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 }; + const calc = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 }; // 计算器返回展示态 const proxy = { - cleanPrice: item.PosiNetNoFeePrice, // 净价 = 手填 - dirtyPrice: item.PosiGrossPrice, - ytm: item.InitYtm + cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), // 0.995 → 99.5(手填) + dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), // 1.0 → 100 + ytm: SwapCalc.bondPriceToCalc(item.InitYtm) // 0.026 → 2.6 }; SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); - item.PosiNetNoFeePrice = proxy.cleanPrice; - item.PosiGrossPrice = proxy.dirtyPrice; - item.InitYtm = proxy.ytm; - expect(item.PosiNetNoFeePrice).toBe(99.5); // 手填(净价):保持 - expect(item.PosiGrossPrice).toBe(100); // 全价:被反算覆盖 - expect(item.InitYtm).toBe(2.5); // 收益率:被反算覆盖 + item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); + item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); + item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); + expect(item.PosiNetNoFeePrice).toBe(0.995); // 手填(净价):存储态保持 0.995(界面仍显示 99.5) + expect(item.PosiGrossPrice).toBe(1.0); // 全价:被反算覆盖,存储态 100/100=1.0 + expect(item.InitYtm).toBe(0.025); // 收益率:被反算覆盖,存储态 2.5/100=0.025 + }); + + // 核心回归:用户输入净价 100(存储态 1.0)时,绝不能因 proxy 残留存储态而再 ÷100 变成 0.01 + test('核心回归:输入净价 100 不会被二次缩小为 1(存储态 0.01)', () => { + const item = { PosiNetNoFeePrice: 1.0, PosiGrossPrice: null, InitYtm: null, + bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; + const calc = { cleanPrice: 100, dirtyPrice: 102.17928767123287, ytm: 4.860445236 }; + const proxy = { + cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), // 1.0 → 100 + dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), + ytm: SwapCalc.bondPriceToCalc(item.InitYtm) + }; + SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); + item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); + item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); + item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); + expect(item.PosiNetNoFeePrice).toBe(1.0); // 存储态仍是 1.0(界面显示 100) + expect(item.PosiGrossPrice).toBeCloseTo(1.0217928767123287, 9); // 102.179.../100 + expect(item.InitYtm).toBeCloseTo(0.04860445236, 9); // 4.860.../100 + }); + + // 多轮不同净价:95 / 105,确保存储态始终与界面输入一致,不会被二次缩小 + test.each([ + [95, 97.5, 5.123456], + [105, 107.3, 4.567890], + [100, 102.17928767123287, 4.860445236] + ])('多轮净价校验:输入净价 %s 时,存储态保持 %s/100 且不会被二次缩小', (inputClean, calcDirty, calcYtm) => { + const item = { PosiNetNoFeePrice: inputClean / 100, PosiGrossPrice: null, InitYtm: null, + bondDriverType: 'CP', bondManual: { CP: true, DP: false, YD: false } }; + const calc = { cleanPrice: inputClean, dirtyPrice: calcDirty, ytm: calcYtm }; + const proxy = { + cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), + dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), + ytm: SwapCalc.bondPriceToCalc(item.InitYtm) + }; + SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual); + item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); + item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); + item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); + expect(item.PosiNetNoFeePrice).toBe(inputClean / 100); + expect(item.PosiGrossPrice).toBeCloseTo(calcDirty / 100, 9); + expect(item.InitYtm).toBeCloseTo(calcYtm / 100, 9); }); }); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js index 2aebcd88..b172c293 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js @@ -308,8 +308,9 @@ const vue = new Vue({ underlyingCode: item.UnderlyingCode, // 模型 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, + // 此处传给计算器前必须 bondPriceToCalc(存储→展示);回写时再 bondCalcPriceToStorage(展示→存储), + // 否则计算器拿到 0.995≈1 当 1% of par 直接发散成离谱值。 + price: SwapCalc.bondPriceToCalc(price), priceType: priceType, targetDate: targetDate }, { alertFn: main.message }).done(function (resp) { @@ -317,20 +318,23 @@ const vue = new Vue({ // 业务层错误(债券不存在/信息不全/参数非法):仅提示,绝不覆盖手工输入 var err = SwapCalc.getBondCalcErrorMessage(resp.obj); if (err) { main.message(err); return; } - // 以既有三字段为代理,调用纯函数(已手动设过的字段不被覆盖),再写回 + // 以既有三字段为代理,调用纯函数(已手动设过的字段不被覆盖),再写回。 + // 关键:proxy 内必须统一为【展示态】(per-100-face),因为 applyBondCalcResult 写入的是计算器返回的展示态。 + // 模型字段是【存储态小数】(percent:true 下 1.00 对应界面 100),所以初始化时要 bondPriceToCalc(×100); + // 若直接用存储态初始化,则用户手填字段被 applyBondCalcResult 跳过后,proxy 中仍残留存储态, + // 后续再 ÷100 就会导致该字段被二次缩小(如输入 100 变成 1)。 var proxy = { - cleanPrice: item.PosiNetNoFeePrice, - dirtyPrice: item.PosiGrossPrice, - ytm: item.InitYtm + cleanPrice: SwapCalc.bondPriceToCalc(item.PosiNetNoFeePrice), + dirtyPrice: SwapCalc.bondPriceToCalc(item.PosiGrossPrice), + ytm: SwapCalc.bondPriceToCalc(item.InitYtm) }; var manual = item.bondManual || { CP: false, DP: false, YD: false }; SwapCalc.applyBondCalcResult(proxy, resp.obj, manual); - // 回写前 ÷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; + // 回写前 bondCalcPriceToStorage(÷100)(展示态→存储态小数):proxy 里均为展示态; + // 模型字段存存储态(0.995),须 ÷100 落回模型,否则配合 percent:true 显示会 ×100 成离谱值。 + item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(proxy.cleanPrice); + item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(proxy.dirtyPrice); + item.InitYtm = SwapCalc.bondCalcPriceToStorage(proxy.ytm); // 名义本金依赖全价(PosiGrossPrice):以净价/收益率为源反算出的全价被回写后, // 直接赋值不会触发组件 input 事件,需在此显式重算,保持名义本金与全价一致。 if (self.calcNotional) self.calcNotional(); @@ -714,8 +718,15 @@ const vue = new Vue({ var thisObj = this; main.post("/pricing/AjaxGetUnderlyingPrice", { underlyingCode: underlyingCode, tradeDate: StartDate }) .done(function (resp) { - item.PosiNetNoFeePrice = otcformat.trading.umprice(resp.obj.netPrice); - item.PosiGrossPrice = otcformat.trading.umprice(resp.obj.price); + if (item.isBond) { + // 债券行情返回的是【展示态/每百元百分比】(如 100),而 PosiNetNoFeePrice/PosiGrossPrice + // 模型字段存【存储态小数】(如 1.0)。非债券标的保持原 umprice 格式化即可。 + item.PosiNetNoFeePrice = SwapCalc.bondCalcPriceToStorage(resp.obj.netPrice); + item.PosiGrossPrice = SwapCalc.bondCalcPriceToStorage(resp.obj.price); + } else { + item.PosiNetNoFeePrice = otcformat.trading.umprice(resp.obj.netPrice); + item.PosiGrossPrice = otcformat.trading.umprice(resp.obj.price); + } thisObj.calcNotional(); }); },