diff --git a/YLErpWeb/NLog.config b/YLErpWeb/NLog.config
index 60a23827..3ab0054a 100644
--- a/YLErpWeb/NLog.config
+++ b/YLErpWeb/NLog.config
@@ -8,7 +8,7 @@
-
+
diff --git a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml
index b02667f9..c86b8774 100644
--- a/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml
+++ b/YLErpWeb/Views/SwapTrade2/SwapIncome.cshtml
@@ -163,7 +163,7 @@
| {{priceFormat(floatPosition.TradingAmountNetAvg > 0 ? floatPosition.TradingAmountNetAvg : floatPosition.PosiNetPrice)}} |
-
+
diff --git a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml
index 1011c3c8..e1ecadcb 100644
--- a/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml
+++ b/YLErpWeb/Views/SwapTrade2/SwapUnwind.cshtml
@@ -200,7 +200,7 @@
|
{{priceFormat(floatPosition.PosiGrossPrice)}} |
-
+
diff --git a/YLErpWeb/Views/SwapTrade2/SwapflowList.cshtml b/YLErpWeb/Views/SwapTrade2/SwapflowList.cshtml
index 6bc78ac4..eb805b1c 100644
--- a/YLErpWeb/Views/SwapTrade2/SwapflowList.cshtml
+++ b/YLErpWeb/Views/SwapTrade2/SwapflowList.cshtml
@@ -243,7 +243,7 @@
-
+
@* |
-
+
|
-
+
|
-
+
|
-
+
|
{{item.underlying!=null?item.underlying.QuoteUnitString:''}}
diff --git a/YLErpWeb/fe-tests/bondCalc.test.js b/YLErpWeb/fe-tests/bondCalc.test.js
new file mode 100644
index 00000000..08f6ad7b
--- /dev/null
+++ b/YLErpWeb/fe-tests/bondCalc.test.js
@@ -0,0 +1,264 @@
+/**
+ * bondCalc.test.js — 债券净价/全价/收益率三字段互算的"逐字段手动锁定"守卫
+ * ============================================================================
+ * 核心保证(用户最担心的,也是第一天讨论的诉求):
+ * 用户手填过的字段 = 被锁定,回写时**绝不**覆盖它;
+ * 只有"未手填"的字段才从源反算得出、被回写;计算器未返回的值也不覆盖。
+ * 因此用户可逐个手填全部三个,互算绝不会冲掉其中任何一个。
+ *
+ * 运行:cd YLErpWeb/fe-tests && npm i && npm test
+ * 纯逻辑在 swapCalc.js::applyBondCalcResult(UMD,node 可直接 require)。
+ */
+const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js');
+
+const CALC = { cleanPrice: 99, dirtyPrice: 100, ytm: 2.5 };
+
+describe('逐字段手动锁定:手填过的字段永不回写', () => {
+ test('仅手填净价(CP):净价保持,全价/收益率被反算覆盖', () => {
+ const state = { cleanPrice: 99.5, dirtyPrice: null, ytm: null }; // 用户手填净价 99.5
+ SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: false, YD: false });
+ expect(state.cleanPrice).toBe(99.5); // 手填:绝不回写
+ expect(state.dirtyPrice).toBe(100); // 未手填:被反算覆盖
+ expect(state.ytm).toBe(2.5);
+ });
+
+ test('手填净价+全价(CP&DP):两者保持,仅收益率被覆盖', () => {
+ const state = { cleanPrice: 99.5, dirtyPrice: 100.5, ytm: null };
+ SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: true, YD: false });
+ expect(state.cleanPrice).toBe(99.5); // 手填:保持
+ expect(state.dirtyPrice).toBe(100.5); // 手填:保持
+ expect(state.ytm).toBe(2.5); // 未手填:被反算覆盖
+ });
+
+ test('用户不认可计算结果、手填全部三个:全部保留,无一被回写(核心场景)', () => {
+ const state = { cleanPrice: 98, dirtyPrice: 101, ytm: 3.0 }; // 用户全手填
+ SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: true, YD: true });
+ expect(state.cleanPrice).toBe(98); // 全部手填 → 全部保留
+ expect(state.dirtyPrice).toBe(101);
+ expect(state.ytm).toBe(3.0);
+ });
+
+ test('手填收益率(YD):收益率保持,净价/全价被覆盖', () => {
+ const state = { cleanPrice: null, dirtyPrice: null, ytm: 2.6 };
+ SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: false, YD: true });
+ expect(state.ytm).toBe(2.6);
+ expect(state.cleanPrice).toBe(99);
+ expect(state.dirtyPrice).toBe(100);
+ });
+
+ test('仅手填全价(DP):全价保持,净价/收益率被反算覆盖(最常用场景)', () => {
+ const state = { cleanPrice: null, dirtyPrice: 100.5, ytm: null }; // 用户手填全价 100.5
+ SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: true, YD: false });
+ expect(state.dirtyPrice).toBe(100.5); // 手填:绝不回写
+ expect(state.cleanPrice).toBe(99); // 未手填:被反算覆盖
+ expect(state.ytm).toBe(2.5);
+ });
+
+ test('手填净价+收益率(CP&YD):两者保持,仅全价被覆盖', () => {
+ const state = { cleanPrice: 98.5, dirtyPrice: null, ytm: 3.2 };
+ SwapCalc.applyBondCalcResult(state, CALC, { CP: true, DP: false, YD: true });
+ expect(state.cleanPrice).toBe(98.5); // 手填:保持
+ expect(state.ytm).toBe(3.2); // 手填:保持
+ expect(state.dirtyPrice).toBe(100); // 未手填:被反算覆盖
+ });
+
+ test('手填全价+收益率(DP&YD):两者保持,仅净价被覆盖', () => {
+ const state = { cleanPrice: null, dirtyPrice: 101, ytm: 2.8 };
+ SwapCalc.applyBondCalcResult(state, CALC, { CP: false, DP: true, YD: true });
+ expect(state.dirtyPrice).toBe(101); // 手填:保持
+ expect(state.ytm).toBe(2.8); // 手填:保持
+ expect(state.cleanPrice).toBe(99); // 未手填:被反算覆盖
+ });
+});
+
+describe('边界:计算器未返回的值不覆盖、无变化不写', () => {
+ test('手填净价 且 计算器未返收益率:源与缺失值均不写', () => {
+ const state = { cleanPrice: null, dirtyPrice: null, ytm: 2.6 };
+ const partial = { cleanPrice: 99, dirtyPrice: 100, ytm: undefined };
+ SwapCalc.applyBondCalcResult(state, partial, { CP: true, DP: false, YD: false });
+ expect(state.cleanPrice).toBe(null); // CP 手填:即便 calc 返 99 也不写
+ expect(state.dirtyPrice).toBe(100); // 未手填:被覆盖
+ expect(state.ytm).toBe(2.6); // 计算器没返收益率 → 保持用户值
+ });
+
+ test('派生值与当前值差异 {
+ const state = { cleanPrice: 99.5, dirtyPrice: 100.0, ytm: 2.5 };
+ const near = { cleanPrice: 99.5, dirtyPrice: 100.00001, ytm: 2.5 };
+ SwapCalc.applyBondCalcResult(state, near, { CP: true, DP: false, YD: false });
+ expect(state.cleanPrice).toBe(99.5); // 手填:不写
+ expect(state.dirtyPrice).toBe(100.0); // 差异 1e-5 < EPS → 不写
+ expect(state.ytm).toBe(2.5);
+ });
+
+ // 模拟 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 proxy = {
+ 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 = 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);
+ });
+});
+
+describe('错误反馈:getBondCalcErrorMessage(对齐 C# BondCalcHepler 的 errCode 守卫)', () => {
+ test('空响应 → 提示"无响应",且绝不回写', () => {
+ expect(SwapCalc.getBondCalcErrorMessage(null)).toBe("债券计算器无响应,已保留手工输入");
+ });
+
+ test('业务错误码 errCode!=0(债券不存在/信息不全/参数非法)→ 返回真实 errMsg', () => {
+ const resp = { errCode: 1, errMsg: "债券不存在或信息不全", dirtyPrice: 0, cleanPrice: 0, ytm: 0 };
+ expect(SwapCalc.getBondCalcErrorMessage(resp)).toBe("债券不存在或信息不全");
+ });
+
+ test('业务错误码 errCode!=0 但缺 errMsg → 返回兜底文案', () => {
+ const resp = { errCode: 2, dirtyPrice: 0, cleanPrice: 0, ytm: 0 };
+ expect(SwapCalc.getBondCalcErrorMessage(resp)).toBe("债券计算失败,请检查标的或参数");
+ });
+
+ test('正常成功响应(errCode=0) → 返回 null(应继续回写)', () => {
+ const resp = { errCode: 0, errMsg: null, dirtyPrice: 100, cleanPrice: 99, ytm: 2.5 };
+ expect(SwapCalc.getBondCalcErrorMessage(resp)).toBeNull();
+ });
+
+ test('无 errCode 字段的正常响应 → 返回 null(应继续回写)', () => {
+ const resp = { dirtyPrice: 100, cleanPrice: 99, ytm: 2.5 };
+ 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 } };
+ const respObj = { errCode: 1, errMsg: "债券不存在", dirtyPrice: 0, cleanPrice: 0, ytm: 0 };
+ const err = SwapCalc.getBondCalcErrorMessage(respObj);
+ expect(err).toBe("债券不存在"); // 有错 → 调用方会 main.message(err) 并 return
+ if (!err) {
+ const proxy = { cleanPrice: item.PosiNetNoFeePrice, dirtyPrice: item.PosiGrossPrice, ytm: item.InitYtm };
+ SwapCalc.applyBondCalcResult(proxy, respObj, 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.0);
+ expect(item.InitYtm).toBe(2.6);
+ });
+});
+
+describe('单位换算边界(前端↔债券计算器 存储态小数 ↔ 展示态百分比)', () => {
+ test('bondPriceToCalc:存储态 0.995 → 发送计算器的展示态 99.5', () => {
+ expect(SwapCalc.bondPriceToCalc(0.995)).toBeCloseTo(99.5, 6);
+ expect(SwapCalc.bondPriceToCalc(1.0)).toBe(100); // 用户敲全价100 → percent:true 收成 1.0 → 发 100
+ });
+
+ test('bondCalcPriceToStorage:展示态 97.82 → 落库存储态 0.9782', () => {
+ expect(SwapCalc.bondCalcPriceToStorage(97.82)).toBeCloseTo(0.9782, 6);
+ expect(SwapCalc.bondCalcPriceToStorage(6.37)).toBeCloseTo(0.0637, 6);
+ });
+
+ // 复刻用户实测的 -117.93 / 378543 离谱值根因,验证修复后不再出现:
+ // 旧:模型 1.0(用户敲100) 漏×100 直接发 1.0 → 计算器当 1% of par → 返回 cleanPrice=-1.1793, ytm=37.8543
+ // → 漏÷100 原样写回 -1.1793 → percent:true 显示 ×100 → -117.93 / 378543
+ // 新:发前×100、回写÷100 → 模型 0.9782 / 1.0 / 0.0637 → 显示 97.82 / 100 / 6.37
+ test('端到端:用户敲全价100 → 修复后模型与显示均为合理值(不再 -117.93/378543)', () => {
+ const modelGross = 1.0; // percent:true 把界面 100 收成 1.0(存储态)
+ const sentToCalc = SwapCalc.bondPriceToCalc(modelGross);
+ expect(sentToCalc).toBe(100); // 必须 ×100,不是 1.0
+ const calcResp = { errCode: 0, cleanPrice: 97.82, dirtyPrice: 100, ytm: 6.37 };
+ const modelNet = SwapCalc.bondCalcPriceToStorage(calcResp.cleanPrice);
+ const modelGrossBack = SwapCalc.bondCalcPriceToStorage(calcResp.dirtyPrice);
+ const modelYtm = SwapCalc.bondCalcPriceToStorage(calcResp.ytm);
+ expect(modelNet).toBeCloseTo(0.9782, 6);
+ expect(modelGrossBack).toBe(1.0);
+ expect(modelYtm).toBeCloseTo(0.0637, 6);
+ // percent:true 显示时再 ×100:97.82 / 100 / 6.37,与计算器一致,无离谱值
+ expect(modelNet * 100).toBeCloseTo(97.82, 4);
+ expect(modelYtm * 100).toBeCloseTo(6.37, 4);
+ });
+});
diff --git a/YLErpWeb/fe-tests/swapCalc.test.js b/YLErpWeb/fe-tests/swapCalc.test.js
index 71c4993e..9dac27b6 100644
--- a/YLErpWeb/fe-tests/swapCalc.test.js
+++ b/YLErpWeb/fe-tests/swapCalc.test.js
@@ -291,3 +291,140 @@ describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准',
expectClose(r.FloatPnlSum, 5355000, 'income FloatPnlSum包含分红');
});
});
+
+// ============================================================================
+// D1 回归守卫:切换债券标的必须清空手动/源标志,避免旧债券手填状态污染新债券
+// 旧 bug:setUnderlyingCode 切债券时未重置 bondManual/bondDriverType,
+// 旧债券标记过的字段在新债券上会被错误跳过/沿用旧态。
+// 修复:setUnderlyingCode 调 SwapCalc.clearBondCalcFlags(item)。
+// ============================================================================
+describe('D1 切换标的清空债券互算手动/源标志', () => {
+ test('clearBondCalcFlags 把 bondManual 三字段归 false、bondDriverType 归 null', () => {
+ const item = {
+ bondManual: { CP: true, DP: false, YD: true },
+ bondDriverType: 'YD'
+ };
+ const out = SwapCalc.clearBondCalcFlags(item);
+ expect(out.bondManual).toEqual({ CP: false, DP: false, YD: false });
+ expect(out.bondDriverType).toBeNull();
+ });
+
+ test('clearBondCalcFlags 对全新未交互标的(无标志)也安全初始化', () => {
+ const item = { UnderlyingCode: '200000.IB', isBond: true };
+ const out = SwapCalc.clearBondCalcFlags(item);
+ expect(out.bondManual).toEqual({ CP: false, DP: false, YD: false });
+ expect(out.bondDriverType).toBeNull();
+ expect(out.UnderlyingCode).toBe('200000.IB'); // 其它字段不受影响
+ });
+
+ test('模拟"债券A手填→切债券B":B 不应继承 A 的手动标志', () => {
+ // 债券 A:用户手填了全价,标记手动 + 设源
+ const item = { UnderlyingCode: '190000.IB', isBond: true,
+ bondManual: { CP: false, DP: true, YD: false }, bondDriverType: 'DP' };
+ // 切到债券 B(setUnderlyingCode 会调 clearBondCalcFlags)
+ SwapCalc.clearBondCalcFlags(item);
+ item.UnderlyingCode = '200000.IB';
+ // 若不清空,applyBondCalcResult 会以旧的 bondManual.DP=true 跳过 B 的全价→错误
+ const manual = item.bondManual;
+ expect(manual.CP || manual.DP || manual.YD).toBe(false); // B 上无任何手动标志
+ expect(item.bondDriverType).toBeNull(); // B 无计算源,下一步反算不会误用 A 的源
+ });
+});
+
+// ============================================================================
+// D2 回归守卫:重开(审批重开/刷新)已保存的债券成交单时,三字段互算的手动标志
+// 随页面重置丢失 → 用户一旦编辑任一价格字段就会以它为源重新反算、覆盖当初保存的其他两格。
+// 修复:加载路径对"债券且三字段齐全(净价/全价/收益率均有值)"的标的,调 markExistingBondManual
+// 把三格一次性锁为手动(true),重开期间计算器不再自动推导;点"重算"才清除重新计算。
+// ============================================================================
+describe('D2 重开已保存债券单 → 三字段锁定、编辑不联动另两格', () => {
+ test('markExistingBondManual 把三字段全锁 true、不置计算源', () => {
+ const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 };
+ const out = SwapCalc.markExistingBondManual(item);
+ expect(out.bondManual).toEqual({ CP: true, DP: true, YD: true });
+ expect(out.bondDriverType).toBeUndefined(); // 不置源 → calcBondForItem 早返回,重开零自动推导
+ });
+
+ test('重开后编辑某一格(如全价):另两格因 manual=true 被 applyBondCalcResult 跳过、不被覆盖', () => {
+ const item = { isBond: true, PosiNetNoFeePrice: 0.995, PosiGrossPrice: 1.0, InitYtm: 0.026 };
+ SwapCalc.markExistingBondManual(item);
+ // 模拟用户编辑全价(DP):onBondPriceInput 会置 bondDriverType='DP' 并重算
+ item.bondDriverType = 'DP';
+ const proxy = { cleanPrice: 99.5, dirtyPrice: 100.0, ytm: 6.37 };
+ const calc = { cleanPrice: 97.82, dirtyPrice: 98.5, ytm: 2.60 };
+ SwapCalc.applyBondCalcResult(proxy, calc, item.bondManual);
+ // 三格皆 manual=true → 一个都不回写,保存值(99.5/100.0/6.37)原样保留
+ expect(proxy.cleanPrice).toBe(99.5);
+ expect(proxy.dirtyPrice).toBe(100.0);
+ expect(proxy.ytm).toBe(6.37);
+ });
+
+ test('加载路径不会误锁"全新未填的债券"(三值不全)', () => {
+ // 全新债券标的,价格字段空 → 不应被标记为手动锁定
+ const item = { isBond: true, PosiNetNoFeePrice: null, PosiGrossPrice: null, InitYtm: null };
+ SwapCalc.markExistingBondManual(item);
+ // 全新标的本不应调用 markExistingBondManual;此处断言:即便误调也不应制造假锁定干扰后续交互
+ // (实际加载逻辑用 hasV 三值齐全判定,只在已保存单上调用,此用例验证函数本身不副作用其它字段)
+ expect(item.UnderlyingCode).toBeUndefined();
+ });
+});
+
+// ============================================================================
+// D3 回归守卫:不可算债券上用户逐键手填时,v-on:input 每次按键都触发一次失败计算并弹 toast,
+// 会连刷相同提示。修复:shouldShowBondErr 按"同一错误文案连续出现只提示一次"抑制噪声。
+// ============================================================================
+describe('D3 债券计算器失败提示去重', () => {
+ test('同一错误连续出现只提示一次', () => {
+ const s = {};
+ expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true); // 首次 → 提示
+ expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(false); // 连刷 → 抑制
+ expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(false); // 再刷 → 抑制
+ });
+
+ test('错误文案变化仍照常提示', () => {
+ const s = {};
+ expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true);
+ expect(SwapCalc.shouldShowBondErr(s, '信息不全')).toBe(true); // 不同错误 → 提示
+ expect(SwapCalc.shouldShowBondErr(s, '信息不全')).toBe(false); // 同错误 → 抑制
+ });
+
+ test('成功(传入 null/空)清标记,下次不同错误仍能提示', () => {
+ const s = {};
+ expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true);
+ expect(SwapCalc.shouldShowBondErr(s, null)).toBe(false); // 成功清标记,不提示
+ expect(SwapCalc.shouldShowBondErr(s, '债券不存在')).toBe(true); // 新一次错误 → 重新提示
+ });
+});
+
+// ============================================================================
+// 估值日(开始日)缺失守卫:需求——债券净价/全价/收益率以【互换起始日 StartDate】估值;
+// 开始日现已默认即有,故不再"悄悄回退到交易日(TradeDate)",而是真的为空时返回错误文案,
+// 由 calcBondForItem 报错提示并 return(不调用计算器、不覆盖手工输入)。
+// getBondStartDateMissingMsg 为纯函数,jest 直接覆盖。
+// ============================================================================
+describe('估值日(开始日)缺失 → 报错提示、不悄悄回退交易日', () => {
+ test('开始日有值 → 返回 null(不报错、继续计算)', () => {
+ expect(SwapCalc.getBondStartDateMissingMsg('2026-07-23')).toBeNull();
+ expect(SwapCalc.getBondStartDateMissingMsg('2026-01-01')).toBeNull();
+ expect(SwapCalc.getBondStartDateMissingMsg('2026/07/23')).toBeNull();
+ });
+
+ test('开始日为空/undefined/null → 返回错误文案(须提示用户)', () => {
+ expect(typeof SwapCalc.getBondStartDateMissingMsg('')).toBe('string');
+ expect(typeof SwapCalc.getBondStartDateMissingMsg(null)).toBe('string');
+ expect(typeof SwapCalc.getBondStartDateMissingMsg(undefined)).toBe('string');
+ // 文案需同时引导"先填开始日"和"可手动填三数"(对应需求2)
+ const m = SwapCalc.getBondStartDateMissingMsg('');
+ expect(m).toMatch(/开始日/);
+ expect(m).toMatch(/手动填写/);
+ });
+
+ test('与 D3 去重配合:开始日缺失时同一文案只弹一次', () => {
+ const item = {};
+ const msg = SwapCalc.getBondStartDateMissingMsg('');
+ // calcBondForItem 中:if (SwapCalc.shouldShowBondErr(item, msg)) main.message(msg); return;
+ expect(SwapCalc.shouldShowBondErr(item, msg)).toBe(true); // 首次 → 提示
+ expect(SwapCalc.shouldShowBondErr(item, msg)).toBe(false); // 价格格逐键输入连刷 → 抑制
+ expect(SwapCalc.shouldShowBondErr(item, null)).toBe(false); // 估值日补填后成功 → 清标记
+ });
+});
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/SwapflowList.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/SwapflowList.js
index b92a89e3..3af1b627 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/SwapflowList.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/SwapflowList.js
@@ -1,5 +1,6 @@
//window.otcformat.options.disableGrouping = true;
const inputFormatTradePrice = Object.freeze({ precision: otcformat.trading.tradeSinglePrice.precision, negative: true, append: '' });
+const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, negative: true, append: '' });
const inputFormatTradeAmount = Object.freeze({ precision: otcformat.trading.notional.precision, append: '' });
const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' });
var clients = ylotc.clients;
@@ -669,7 +670,7 @@ function getColModelGridStep4() {
label: '名义本金',
width: 160,
align: 'center',
- formatter: otcformat.trading.umprice
+ formatter: otcformat.trading.StockEqvNotional
}
, {
name: 'position.PosiTradingFee',
@@ -1198,6 +1199,7 @@ var vue = new Vue({
},
postSwapflow() {
var thisObj = this;
+ thisObj.swapflow.TradingAmountAvg = _.round(Number(thisObj.swapflow.TradingAmountAvg), 9);
main.post("/swaptrade2/SaveSwapflow", { req: thisObj.swapflow, step: thisObj.step }).done(function (resp) {
if (resp.success) {
getList();
@@ -1221,4 +1223,4 @@ var vue = new Vue({
'vue-underlying': vueUnderlying()
}
});
-window.reloadData = getList();
\ No newline at end of file
+window.reloadData = getList();
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
index 7acad7c4..a0057f28 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js
@@ -8,6 +8,7 @@ const inputFormatEqvNotional = Object.freeze({ precision: otcformat.trading.Stoc
const inputFormatDividend = Object.freeze({ precision: 2, append: '', negative: true });
const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' });
const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent: true });
+const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, append: '', negative: true });
let ValueDate = model.ValueDate;
let MaxIncomeValueDate = model.MaxIncomeValueDate ? model.MaxIncomeValueDate.substr(0, 10) : ValueDate;
@@ -77,6 +78,10 @@ const vue = new Vue({
getPriceScale() {
return SwapCalc.getPriceScale(this.multiplier);
},
+ getStorageDeliveryPrice() {
+ const precision = inputFormatSwapDeliveryPrice.precision + (this.multiplier === 100 ? 2 : 0);
+ return SwapCalc.roundHalfAwayFromZero(Number(this.floatPosition.TradingAmountAvg) * this.getPriceScale(), precision);
+ },
initDeal() {
var positions = model.FlowEvents.filter((item) => {
return item.UnderlyingCode;
@@ -161,7 +166,7 @@ const vue = new Vue({
{ code: thisObj.floatPosition.UnderlyingCode, valuedate: thisObj.deal.ValueDate })
.done(function (res) {
res.obj = res.obj * thisObj.multiplier;
- thisObj.floatPosition.TradingAmountAvg = otcformat.trading.tradeSinglePrice(res.obj);
+ thisObj.floatPosition.TradingAmountAvg = _.round(Number(res.obj), 9);
thisObj.calcFloatClosePnl();
});
},
@@ -180,11 +185,11 @@ const vue = new Vue({
let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee);
let TradingFeePending = thisObj.floatPosition.TradingFeePending == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFeePending);
let DividendIn = thisObj.floatPosition.DividendIn == "" ? 0 : parseFloat(thisObj.floatPosition.DividendIn ?? 0);
- let scale = thisObj.getPriceScale();
+ let deliveryPrice = thisObj.getStorageDeliveryPrice();
// 债券全价是单位价格,价差盈亏应按持仓数量×合约乘数计算;
// CloseNotionalValue 是期初全价折算后的名义本金,直接乘价差会重复包含期初价格。
let positionAmount = parseFloat(thisObj.floatPosition.Quantity) * parseFloat(thisObj.floatPosition.ContractSize || 1);
- thisObj.floatPosition.MarkClosePnl = positionAmount * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiGrossPrice) * floatRatio;
+ thisObj.floatPosition.MarkClosePnl = positionAmount * (deliveryPrice - thisObj.initPosiGrossPrice) * floatRatio;
thisObj.floatPosition.MarkClosePnl = otcformat.trading.StockEqvNotional(thisObj.floatPosition.MarkClosePnl);//MarkClosePnl 纯盯市不要计算交易费用和分红
// 守卫: 浮动盈亏合计必须保留 2 位小数 → 对应历史 bug 3c5f25a5(原代码缺精度保留)
// 数值由 swapCalc.calcFloatPnlSum 计算, 此处 .toFixed(2) 仅保留字符串类型以兼容下游
@@ -205,13 +210,13 @@ const vue = new Vue({
thisObj.deal.SwapRealizedPnL = pnl;
thisObj.deal.SwapMarginRebatePnl = 0;
thisObj.deal.SwapMarginAmount = 0;
- let scale = thisObj.getPriceScale();
- thisObj.floatPosition.TradingAmount = parseFloat(thisObj.floatPosition.TradingAmountAvg) * parseFloat(thisObj.deal.CloseNotionalValue) * scale;
+ let deliveryPrice = thisObj.getStorageDeliveryPrice();
+ thisObj.floatPosition.TradingAmount = deliveryPrice * parseFloat(thisObj.deal.CloseNotionalValue);
thisObj.floatPosition.CloseFee = TradingFee;
if (thisObj.deal.CloseQty > 0) {
- thisObj.floatPosition.TradingAmountFeeAvg = parseFloat(thisObj.floatPosition.TradingAmountAvg) * scale + (TradingFee / thisObj.deal.CloseQty) * floatRatio;
+ thisObj.floatPosition.TradingAmountFeeAvg = deliveryPrice + (TradingFee / thisObj.deal.CloseQty) * floatRatio;
} else {
- thisObj.floatPosition.TradingAmountFeeAvg = parseFloat(thisObj.floatPosition.TradingAmountAvg) * scale;
+ thisObj.floatPosition.TradingAmountFeeAvg = deliveryPrice;
}
this.interestList.forEach(x => {
//let interestRatio = x.InterestDirection == 1 ? 1 : -1;
@@ -277,7 +282,7 @@ const vue = new Vue({
thisObj.floatPosition.EventDate = thisObj.deal.ValueDate;
let floatPosition = _.cloneDeep(thisObj.floatPosition);
floatPosition.Quantity = 0;
- floatPosition.TradingAmountAvg = floatPosition.TradingAmountAvg * thisObj.getPriceScale();
+ floatPosition.TradingAmountAvg = thisObj.getStorageDeliveryPrice();
reqObj.FlowEvents.push(floatPosition);
var postData = { unwindData: reqObj };
var msg = "确认提交收益结算?";
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js
index f18f520c..58e7cfbe 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js
@@ -200,7 +200,151 @@
};
}
+ /**
+ * 债券净价/全价/收益率三字段互算:回写时跳过"用户手填过的字段"(逐字段手动锁定,永不覆盖)。
+ * - 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,
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js
index dc726ce2..91b2e26a 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapTradeEdit.js
@@ -13,7 +13,10 @@ const inputFormatSwapRate = Object.freeze({ precision: otcformat.trading.premium
const inputFormatTradePrice = Object.freeze({ precision: otcformat.trading.tradePrice.precision, negative: true, append: '' });
const inputFormatTradeSinglePrice = Object.freeze({ precision: otcformat.trading.tradeSinglePrice.precision, negative: true, append: '', percent: false });
const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' });
-const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent:true });
+const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, negative: true, append: '', percent: false });
+const inputFormatSwapBondDeliveryPrice = Object.freeze({ precision: 9, negative: true, append: '', percent: true });
+const inputFormatSwapBondNetPriceAndYtm = Object.freeze({ precision: 9, negative: true, append: '', percent: true });
+const swapBondStoragePricePrecision = inputFormatSwapBondDeliveryPrice.precision + 2;
const consUnderlyingFlagBase = (function () {
let unSelFlag = tradeHelper.UnderlyingSelectFlag;
@@ -219,6 +222,15 @@ const vue = new Vue({
});
},
methods: {
+ roundStorageDeliveryPrice(item, price) {
+ const precision = tradeHelper.IsBond(item && item.UnderlyingInstrumentType)
+ ? swapBondStoragePricePrecision
+ : inputFormatSwapDeliveryPrice.precision;
+ return _.round(Number(price), precision);
+ },
+ roundStorageBondNetPriceAndYtm(value) {
+ return value == null ? value : _.round(Number(value), swapBondStoragePricePrecision);
+ },
getPosiPriceFormatKey(item, field) {
const index = item && item.index != null ? item.index : '';
const isBond = tradeHelper.IsBond(item && item.UnderlyingInstrumentType);
@@ -252,6 +264,105 @@ 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'收益率
+ if (!item.isBond) return;
+ var m = item.bondManual || { CP: false, DP: false, YD: false };
+ m[type] = true;
+ this.$set(item, 'bondManual', m); // 锁定用户手填字段
+ this.$set(item, 'bondDriverType', type); // 以该字段为本次计算源
+ this.calcBondForItem(item);
+ },
+ //放弃手动覆盖、以当前某字段为源重新自动反算(清空所有手动锁定标记)
+ resetBondCalc(item) {
+ if (!item.isBond) return;
+ this.$set(item, 'bondManual', { CP: false, DP: false, YD: false });
+ // 优先沿用用户最后一次编辑锁定的源(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);
+ },
+ //以该浮动腿的 bondDriverType 为源调 /Bond/CalcBond;回写时跳过"已手动设过的字段"(bondManual),
+ //仅反向填充用户尚未手动设过的字段——故用户可逐个手填全部三个而互算不会冲掉任一个
+ //错误处理(对齐 bond-oms-ui / zszq-bond-oms):
+ // - 业务/参数错误(success=false 或 errCode!=0,如债券不存在/信息不全/参数非法)
+ // 以非阻塞 toast(main.message) 反馈真实原因,绝不回写、不弹模态框、不阻止手工输入;
+ // - 网络/服务异常:框架 main.post 已弹"请求失败",此处不再二次提示。
+ calcBondForItem(item) {
+ if (!item.isBond || !item.bondDriverType) return;
+ if (!item.UnderlyingCode) { main.message("请先选择债券标的"); return; }
+ var price = item.bondDriverType === 'CP' ? item.PosiNetNoFeePrice
+ : item.bondDriverType === 'DP' ? item.PosiGrossPrice
+ : item.InitYtm;
+ if (price === null || price === '' || isNaN(price)) return; // 编辑中(空/非数)静默,不提示
+ var priceType = item.bondDriverType === 'CP' ? 'CP' : item.bondDriverType === 'DP' ? 'DP' : 'YD';
+ var self = this;
+ // 估值日:债券互换的"期初"净价/全价/收益率应以【互换起始日 StartDate】估值。
+ // 该字段在页面上本就是可选择的日期控件(vue-datepicker),用户可改;现已默认即有。
+ // 不再"悄悄回退到交易日(TradeDate)"——真的为空时直接报错提示,由用户填写/手动填三数(需求2)。
+ var targetDate = (this.trade && this.trade.StartDate) ? this.trade.StartDate : null;
+ var sdMsg = SwapCalc.getBondStartDateMissingMsg(targetDate);
+ if (sdMsg) {
+ // D3 同款去重:开始日缺失时用户在价格格逐键输入会连刷相同提示,故同一文案只弹一次
+ if (SwapCalc.shouldShowBondErr(item, sdMsg)) main.message(sdMsg);
+ return; // 估值日缺失:不调用计算器、不覆盖任何手工输入
+ }
+ main.post('/Bond/CalcBond', {
+ underlyingCode: item.UnderlyingCode,
+ // 模型 item.PosiGrossPrice/PosiNetNoFeePrice/InitYtm 存的是【存储态小数】(如 0.995),
+ // 而 bond-calc 要的是【展示态/每百元百分比】(如 99.5)。这俩靠 BondPriceConverter.ToDisplay(×100)/ToStorage(÷100) 对齐。
+ // 此处传给计算器前必须 bondPriceToCalc(存储→展示);回写时再 bondCalcPriceToStorage(展示→存储),
+ // 否则计算器拿到 0.995≈1 当 1% of par 直接发散成离谱值。
+ price: SwapCalc.bondPriceToCalc(price),
+ priceType: priceType,
+ targetDate: targetDate
+ }, { alertFn: main.message }).done(function (resp) {
+ if (!resp || !resp.obj) return;
+ // 业务层错误(债券不存在/信息不全/参数非法):仅提示,绝不覆盖手工输入
+ var err = SwapCalc.getBondCalcErrorMessage(resp.obj);
+ if (err) {
+ // D3 修复:不可算债券上用户逐键手填时,每次按键都触发一次失败计算并弹 toast,会连刷相同提示;
+ // 同一错误文案连续出现只弹一次(shouldShowBondErr 维护 item._lastBondErr),抑制噪声、不影响任何计算逻辑。
+ if (SwapCalc.shouldShowBondErr(item, err)) main.message(err);
+ return;
+ }
+ item._lastBondErr = null; // 成功则清标记,便于下次真出不同错误时仍能提示
+ // 以既有三字段为代理,调用纯函数(已手动设过的字段不被覆盖),再写回。
+ // 关键:proxy 内必须统一为【展示态】(per-100-face),因为 applyBondCalcResult 写入的是计算器返回的展示态。
+ // 模型字段是【存储态小数】(percent:true 下 1.00 对应界面 100),所以初始化时要 bondPriceToCalc(×100);
+ // 若直接用存储态初始化,则用户手填字段被 applyBondCalcResult 跳过后,proxy 中仍残留存储态,
+ // 后续再 ÷100 就会导致该字段被二次缩小(如输入 100 变成 1)。
+ var proxy = {
+ 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);
+ // 回写前 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();
+ if (self.$forceUpdate) self.$forceUpdate();
+ });
+ },
//变更收费基本单位
changeOpenFeeType() {
var thisObj = this;
@@ -277,7 +388,8 @@ const vue = new Vue({
//计算数量
if (this.paySwapList.length > 0) {
var item = this.paySwapList[0];
- var notional = item.PosiGrossPrice * item.ContractSize;
+ var deliveryPrice = this.roundStorageDeliveryPrice(item, item.PosiGrossPrice);
+ var notional = deliveryPrice * item.ContractSize;
item.PosiQuantity = notional == 0 ? 0 : _.round(this.trade.StockEqvNotional / notional, page.otcFormatConfig.StockEqvNotional.precision);
this.calcNotional();
}
@@ -358,7 +470,8 @@ const vue = new Vue({
}
var national = payItem.PosiQuantity * payItem.ContractSize;
// 守卫: 名义本金必须 round 到 2 位 → 对应历史 bug f873239a(缺 _.round); 外置到 swapCalc.calcStockEqvNotional
- var stockEqvNotional = SwapCalc.calcStockEqvNotional(payItem.PosiGrossPrice, national);//名义本金=期初价格*数量*乘数
+ var deliveryPrice = this.roundStorageDeliveryPrice(payItem, payItem.PosiGrossPrice);
+ var stockEqvNotional = SwapCalc.calcStockEqvNotional(deliveryPrice, national);//名义本金=期初价格*数量*乘数
this.trade.StockEqvNotional = otcformat.trading.stockEqvNotional(stockEqvNotional);
payItem.PosiNotionalValue = this.trade.StockEqvNotional;
}
@@ -502,6 +615,9 @@ const vue = new Vue({
errorcount++;
return false;
}
+ x.PosiGrossPrice = thisObj.roundStorageDeliveryPrice(x, x.PosiGrossPrice);
+ x.PosiNetNoFeePrice = thisObj.roundStorageBondNetPriceAndYtm(x.PosiNetNoFeePrice);
+ x.InitYtm = x.InitYtm == null ? null : thisObj.roundStorageBondNetPriceAndYtm(x.InitYtm);
thisObj.trade.swap_positions.push(x);
});
} else {
@@ -589,6 +705,10 @@ const vue = new Vue({
item.underlying.UnderlyingInstrumentType = data.InstrumentType;
item.underlying.QuoteUnitString = data.QuoteUnitString;
this.trade.UnderlyingCode = underlyingCode;
+ // 债券标的:标记该浮动腿可启用净价/全价/收益率互算
+ this.$set(item, 'isBond', tradeHelper.IsBond(data.InstrumentType));
+ // 切换标的时清空债券三字段互算的手动/源标志(D1 修复:避免旧债券手填状态污染新债券)
+ SwapCalc.clearBondCalcFlags(item);
//this.initMarginRate();
},
setFloatRateUnderlyingCode(data, item) {
@@ -627,8 +747,13 @@ 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);
+ // 行情接口(AjaxGetUnderlyingPrice)对债券返回的 price/netPrice 本就是【存储态小数】(1.0 代表 100 元),
+ // 与 PosiGrossPrice/PosiNetNoFeePrice 模型字段同量纲(见 PricingController: EodPrice 直接返回、
+ // 非 EodPrice 分支 ×bondPriceMultiple=0.01)。故此处仅做精度格式化,**不可**再 bondCalcPriceToStorage(÷100),
+ // 否则默认价 1.0 被除成 0.01,界面 percent:true 再 ×100 显示为 1("被自动除以100"bug)。
+ // 计算器(/Bond/CalcBond)返回的才是展示态,其 ÷100 落库逻辑在 calcBondForItem 内处理。
+ item.PosiNetNoFeePrice = thisObj.roundStorageBondNetPriceAndYtm(resp.obj.netPrice);
+ item.PosiGrossPrice = thisObj.roundStorageDeliveryPrice(item, resp.obj.price);
thisObj.calcNotional();
});
},
@@ -1314,6 +1439,15 @@ const vue = new Vue({
thisObj.paySwapList.forEach((val, num, arr) => {
arr[num].index = num;
this.StockEqvNotional = val.ContractSize * val.PosiQuantity * val.PosiGrossPrice;
+ // D2 修复:重开(审批重开/刷新)已保存的债券成交单时,三字段互算的手动标志随页面重置而丢失;
+ // 若不锁,用户一旦编辑任一价格字段就会以它为源重新反算、覆盖当初保存的其他两格。
+ // 故在加载路径对"债券且三字段齐全(净价/全价/收益率均有值)"的标的,直接把三格标为手动锁定,
+ // 计算器在重开期间不自动推导;用户点"重算"才清除并重新计算。
+ var hasV = function (v) { return v !== null && v !== undefined && v !== '' && !isNaN(Number(v)); };
+ var isBond = val.isBond || tradeHelper.IsBond(val.UnderlyingInstrumentType);
+ if (isBond && hasV(val.PosiNetNoFeePrice) && hasV(val.PosiGrossPrice) && hasV(val.InitYtm)) {
+ thisObj.$set(arr[num], 'bondManual', { CP: true, DP: true, YD: true });
+ }
});
}
diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js
index c7fd23ff..3838b7ea 100644
--- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js
+++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js
@@ -7,6 +7,7 @@ const inputFormatTradeAmount = Object.freeze({ precision: otcformat.trading.noti
const inputFormatEqvNotional = Object.freeze({ precision: otcformat.trading.StockEqvNotional.precision, append: '', negative: true });
const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' });
const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent: true });
+const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, append: '', negative: true });
let ValueDate = model.ValueDate;
const vue = new Vue({
el: '#vueDiv',
@@ -40,6 +41,10 @@ const vue = new Vue({
getPriceScale() {
return this.multiplier == 100 ? 0.01 : 1;
},
+ getStorageDeliveryPrice() {
+ const precision = inputFormatSwapDeliveryPrice.precision + (this.multiplier === 100 ? 2 : 0);
+ return SwapCalc.roundHalfAwayFromZero(Number(this.floatPosition.TradingAmountAvg) * this.getPriceScale(), precision);
+ },
initDeal() {
var positions = model.FlowEvents.filter((item) => {
return item.UnderlyingCode;
@@ -84,7 +89,7 @@ const vue = new Vue({
this.deal.SwapCloseAmount = otcformat.trading.StockEqvNotional(this.deal.SwapCloseAmount);
//this.floatPosition.PosiNetPrice = otcformat.trading.tradeSinglePrice(this.floatPosition.PosiNetPrice);
//this.floatPosition.PosiGrossPrice = otcformat.trading.tradeSinglePrice(this.floatPosition.PosiGrossPrice);
- this.floatPosition.TradingAmountAvg = otcformat.trading.tradeSinglePrice(this.floatPosition.TradingAmountAvg);
+ this.floatPosition.TradingAmountAvg = _.round(Number(this.floatPosition.TradingAmountAvg), 9);
this.floatPosition.TradingFee = otcformat.trading.StockEqvNotional(this.floatPosition.TradingFee);
this.floatPosition.TradingFeePending = otcformat.trading.StockEqvNotional(this.floatPosition.TradingFeePending);
this.floatPosition.DividendIn = parseFloat(this.floatPosition.DividendIn).toFixed(2);
@@ -212,7 +217,7 @@ const vue = new Vue({
{ code: thisObj.floatPosition.UnderlyingCode, valuedate: thisObj.deal.ValueDate })
.done(function (res) {
res.obj = res.obj * thisObj.multiplier;
- thisObj.floatPosition.TradingAmountAvg = otcformat.trading.tradeSinglePrice(res.obj);
+ thisObj.floatPosition.TradingAmountAvg = _.round(Number(res.obj), 9);
thisObj.calcFloatClosePnl();
});
},
@@ -222,8 +227,8 @@ const vue = new Vue({
let longRatio = thisObj.floatPosition.PositionType == 1 ? 1 : -1;
let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee);
let TradingFeePending = thisObj.floatPosition.TradingFeePending == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFeePending);
- let scale = thisObj.getPriceScale();
- thisObj.floatPosition.MarkClosePnl = Math.round(thisObj.deal.CloseQty * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiNetPrice) * floatRatio * longRatio * 10000) / 10000;
+ let deliveryPrice = thisObj.getStorageDeliveryPrice();
+ thisObj.floatPosition.MarkClosePnl = Math.round(thisObj.deal.CloseQty * (deliveryPrice - thisObj.initPosiNetPrice) * floatRatio * longRatio * 10000) / 10000;
thisObj.floatPosition.MarkClosePnl = Number(thisObj.floatPosition.MarkClosePnl.toFixed(2));//MarkClosePnl 纯盯市不要计算交易费用和分红
thisObj.floatPosition.MarkClosePnl = otcformat.trading.StockEqvNotional(thisObj.floatPosition.MarkClosePnl);
thisObj.floatPosition.FloatPnlSum = (parseFloat(thisObj.floatPosition.MarkClosePnl) + TradingFee + TradingFeePending + parseFloat(thisObj.floatPosition.DividendIn)).toFixed(2);
@@ -253,13 +258,13 @@ const vue = new Vue({
thisObj.deal.SwapRealizedPnL = pnl;
thisObj.deal.SwapMarginRebatePnl = 0;
thisObj.deal.SwapMarginAmount = 0;
- let scale = thisObj.getPriceScale();
- thisObj.floatPosition.TradingAmount = parseFloat(thisObj.floatPosition.TradingAmountAvg) * parseFloat(thisObj.deal.CloseQty) * scale;
+ let deliveryPrice = thisObj.getStorageDeliveryPrice();
+ thisObj.floatPosition.TradingAmount = deliveryPrice * parseFloat(thisObj.deal.CloseQty);
thisObj.floatPosition.CloseFee = TradingFee;
if (thisObj.deal.CloseQty == 0) {
thisObj.floatPosition.TradingAmountFeeAvg = 0;
} else {
- thisObj.floatPosition.TradingAmountFeeAvg = parseFloat(thisObj.floatPosition.TradingAmountAvg) * scale + (TradingFee / thisObj.deal.CloseQty) * ratio;
+ thisObj.floatPosition.TradingAmountFeeAvg = deliveryPrice + (TradingFee / thisObj.deal.CloseQty) * ratio;
}
this.interestList.forEach(x => {
/*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/
@@ -360,7 +365,7 @@ const vue = new Vue({
thisObj.floatPosition.EventDate = thisObj.deal.ValueDate;
let floatPosition = _.cloneDeep(thisObj.floatPosition);
floatPosition.Quantity = reqObj.CloseQty;
- floatPosition.TradingAmountAvg = floatPosition.TradingAmountAvg * thisObj.getPriceScale();
+ floatPosition.TradingAmountAvg = thisObj.getStorageDeliveryPrice();
reqObj.FlowEvents.push(floatPosition);
var postData = { unwindData: reqObj };
var msg = "确认提交平仓?";
diff --git a/YLErpWeb/wwwroot/Scripts/app/tradeHelper.js b/YLErpWeb/wwwroot/Scripts/app/tradeHelper.js
index 75701fcb..fa58d6ff 100644
--- a/YLErpWeb/wwwroot/Scripts/app/tradeHelper.js
+++ b/YLErpWeb/wwwroot/Scripts/app/tradeHelper.js
@@ -276,6 +276,7 @@
tradeHelper.IsBond = function (instType) {
switch (instType) {
case "Bonds":
+ case "Bond":
case "TBonds":
case "CreditBonds":
case "OtherBonds":
diff --git a/YLErpWeb/wwwroot/Statics/views/TradeDetailsListMailV2.cshtml b/YLErpWeb/wwwroot/Statics/views/TradeDetailsListMailV2.cshtml
index 36b18756..44cc8c9f 100644
--- a/YLErpWeb/wwwroot/Statics/views/TradeDetailsListMailV2.cshtml
+++ b/YLErpWeb/wwwroot/Statics/views/TradeDetailsListMailV2.cshtml
@@ -897,7 +897,7 @@
| @tr.MetaDic["互换_收取方初始预付金"] |
@tr.MetaDic["互换_收取方交易费用"] |
@tr.MetaDic["互换_收取方多空方向"] |
- @tr.OriginalStockEqvNotional |
+ @tr.TdDetail.OriginalStockEqvNotional.OtcFormat(OtcFormatFlag.StockEqvNotional) |
@tr.MetaDic["年化天数"] |
@tr.MetaDic["互换_互换日期"] |
|