diff --git a/YLErpWeb/fe-tests/jest.config.js b/YLErpWeb/fe-tests/jest.config.js index d60ebdc6..b06bd582 100644 --- a/YLErpWeb/fe-tests/jest.config.js +++ b/YLErpWeb/fe-tests/jest.config.js @@ -25,6 +25,7 @@ module.exports = { // 覆盖率配置(--coverage 时生效) collectCoverageFrom: [ '../wwwroot/Scripts/app/swaptrade/swapCalc.js', + '../wwwroot/Scripts/app/swaptrade/unwindBondCalc.js', '../wwwroot/Scripts/fast/fastVue.base.js', // 逐步加入更多文件 ], diff --git a/YLErpWeb/fe-tests/unwindBondCalc.test.js b/YLErpWeb/fe-tests/unwindBondCalc.test.js new file mode 100644 index 00000000..792617bd --- /dev/null +++ b/YLErpWeb/fe-tests/unwindBondCalc.test.js @@ -0,0 +1,136 @@ +/** + * unwindBondCalc.test.js — 平仓页 DP↔YD 两字段互算纯函数(EQD-6953) + * ============================================================================ + * 冻结三条与录入页不同、极易被"顺手统一"改坏的约定: + * 1. 发计算器的价格是【展示态直传】,不 ×100(录入页是存储态 ×100); + * 2. ExitYtm 保留 4 位、四舍五入(AwayFromZero)——结算确认书导出固定 4 位不去零, + * 界面精度不得低于导出要求; + * 3. 失败只清【对方】字段(平仓页无净价列,仅 DP/YD 两字段)。 + * + * 运行:cd YLErpWeb/fe-tests && npx jest unwindBondCalc + */ +const UnwindBondCalc = require('../wwwroot/Scripts/app/swaptrade/unwindBondCalc.js'); + +const CALC = { cleanPrice: 98.5, dirtyPrice: 99.5, ytm: 6.3721 }; + +describe('getCalcRequest:展示态直传,不做录入页的 ×100 换算', () => { + test('DP 回车:price 原样 99.5(若误加 ×100 会变成 9950),priceType=DP,估值日=平仓日', () => { + const req = UnwindBondCalc.getCalcRequest('240004.IB', 99.5, 'DP', '2026-08-20'); + expect(req.price).toBe(99.5); + expect(req.priceType).toBe('DP'); + expect(req.targetDate).toBe('2026-08-20'); + expect(req.underlyingCode).toBe('240004.IB'); + expect(req.source).toBe('unwind'); + }); + + test('YD 回车:收益率展示态直传(6.3721 而非 0.063721)', () => { + const req = UnwindBondCalc.getCalcRequest('240004.IB', 6.3721, 'YD', '2026-08-20'); + expect(req.price).toBe(6.3721); + expect(req.priceType).toBe('YD'); + }); + + test('估值日为空时 targetDate 为 null(兜底,调用方应先校验)', () => { + const req = UnwindBondCalc.getCalcRequest('240004.IB', 99.5, 'DP', null); + expect(req.targetDate).toBeNull(); + }); +}); + +describe('roundExitYtm:4 位小数、四舍五入(远离零)', () => { + test('第 5 位进位', () => { + expect(UnwindBondCalc.roundExitYtm(6.37215)).toBe(6.3722); + }); + test('第 5 位舍去', () => { + expect(UnwindBondCalc.roundExitYtm(6.37214)).toBe(6.3721); + }); + test('负收益率同样远离零(负利率债)', () => { + expect(UnwindBondCalc.roundExitYtm(-0.00005)).toBe(-0.0001); + expect(UnwindBondCalc.roundExitYtm(-1.23445)).toBe(-1.2345); + }); +}); + +describe('applyCalcResult:源字段保持、仅回写对方字段', () => { + test('DP 为源:TradingAmountAvg 保持手输值,ExitYtm 被反算覆盖(4位)', () => { + const state = { TradingAmountAvg: 100.123456, ExitYtm: null }; + UnwindBondCalc.applyCalcResult(state, { ytm: 6.37215 }, 'DP'); + expect(state.TradingAmountAvg).toBe(100.123456); // 源:保持 + expect(state.ExitYtm).toBe(6.3722); // 对方:反算+4位取整 + }); + + test('YD 为源:ExitYtm 保持手输值,TradingAmountAvg 被反算覆盖(展示态直写)', () => { + const state = { TradingAmountAvg: null, ExitYtm: 6.3721 }; + UnwindBondCalc.applyCalcResult(state, { dirtyPrice: 99.5023 }, 'YD'); + expect(state.ExitYtm).toBe(6.3721); // 源:保持 + expect(state.TradingAmountAvg).toBe(99.5023); // 对方:直写(不 ÷100) + }); + + test('计算器未返回对方值时不覆盖(保留手工输入)', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3 }; + UnwindBondCalc.applyCalcResult(state, { ytm: null }, 'DP'); + expect(state.ExitYtm).toBe(6.3); + UnwindBondCalc.applyCalcResult(state, { dirtyPrice: undefined }, 'YD'); + expect(state.TradingAmountAvg).toBe(100.5); + }); + + test('回写值与现值相同(<1e-9)时不写,避免光标跳动', () => { + const state = { TradingAmountAvg: 100, ExitYtm: 6.3721 }; + UnwindBondCalc.applyCalcResult(state, { ytm: 6.3721 }, 'DP'); + expect(state.ExitYtm).toBe(6.3721); + }); +}); + +describe('applyCalcFailure:保留源字段、只清对方字段、标识全清', () => { + test('DP 为源失败:ExitYtm 清空、全价保留', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondDriverType: 'DP', bondAuto: { DP: true, YD: false } }; + UnwindBondCalc.applyCalcFailure(state, 'DP'); + expect(state.TradingAmountAvg).toBe(100.5); + expect(state.ExitYtm).toBeNull(); + expect(state.bondDriverType).toBeNull(); + expect(state.bondAuto).toEqual({ DP: false, YD: false }); + expect(state.bondRev).toEqual({ DP: false, YD: false }); + }); + + test('YD 为源失败:TradingAmountAvg 清空、收益率保留', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondDriverType: 'YD' }; + UnwindBondCalc.applyCalcFailure(state, 'YD'); + expect(state.ExitYtm).toBe(6.3); + expect(state.TradingAmountAvg).toBeNull(); + }); +}); + +describe('标识状态机(交互约定2/3,对齐录入页)', () => { + test('成功:源字段标源、对方标 AUTO、REV 清空', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: null }; + UnwindBondCalc.applyCalcSuccess(state, 'DP'); + expect(state.bondDriverType).toBe('DP'); + expect(state.bondAuto).toEqual({ DP: false, YD: true }); + expect(state.bondRev).toEqual({ DP: false, YD: false }); + }); + + test('手动编辑未回车:清 源/AUTO、本字段累计 REV、不清对方 REV', () => { + const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondRev: { DP: true, YD: false } }; + UnwindBondCalc.applyManualEdit(state, 'YD'); + expect(state.bondDriverType).toBeNull(); + expect(state.bondAuto).toEqual({ DP: false, YD: false }); + expect(state.bondRev).toEqual({ DP: true, YD: true }); // 累计:DP 的 REV 保留 + }); + + test('bondRev 缺失时自动初始化', () => { + const state = {}; + UnwindBondCalc.applyManualEdit(state, 'DP'); + expect(state.bondRev).toEqual({ DP: true, YD: false }); + }); +}); + +describe('FIELDS 映射与 hasValue 守卫', () => { + test('字段映射', () => { + expect(UnwindBondCalc.FIELDS).toEqual({ DP: 'TradingAmountAvg', YD: 'ExitYtm' }); + }); + test('hasValue:空串/null/undefined/NaN 为 false,0 为 true', () => { + expect(UnwindBondCalc.hasValue('')).toBe(false); + expect(UnwindBondCalc.hasValue(null)).toBe(false); + expect(UnwindBondCalc.hasValue(undefined)).toBe(false); + expect(UnwindBondCalc.hasValue(NaN)).toBe(false); + expect(UnwindBondCalc.hasValue(0)).toBe(true); + expect(UnwindBondCalc.hasValue('6.37')).toBe(true); + }); +}); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js new file mode 100644 index 00000000..dc533376 --- /dev/null +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/unwindBondCalc.js @@ -0,0 +1,128 @@ +/** + * unwindBondCalc.js — 平仓页 期末标的交割全价 ↔ 期末标的结算收益率(ExitYtm) 两字段互算(EQD-6953) + * ============================================================================ + * 与录入页(swapTradeEdit.js + swapCalc.js 债券三字段互算)的关键差异,【勿"顺手统一"】: + * 1. 单位状态:录入页三字段(CP/DP/YD)存【存储态小数】(0.995),发计算器前 ×100、回写 ÷100 + * (bondPriceToCalc/bondCalcPriceToStorage);平仓页 TradingAmountAvg 在 initDeal 已转 + * 【展示态百分数】(99.5),ExitYtm 亦定义为展示态(6.3721)——发计算器不换算、回写不换算。 + * 提交时的存储态转换仍由既有 getStorageDeliveryPrice()/dataFormat() 负责。 + * 2. 字段集:只有 DP(TradingAmountAvg)/YD(ExitYtm) 两个字段(平仓页无净价列), + * 失败只清对方字段(录入页 applyBondCalcFailure 清另外两个,字段名也不同,故不复用)。 + * 3. 估值日:用平仓日 deal.ValueDate(结算语义);录入页用互换起始日 StartDate。 + * 4. 精度:ExitYtm 保留 4 位小数、四舍五入(AwayFromZero)——结算确认书导出要求 + * 固定 4 位不去零(ToString("0.0000")),界面精度不得低于该要求。 + * + * 浏览器:挂 window.UnwindBondCalc(需在 swapCalc.js 之后、unwindSwapTrade.js 之前加载)。 + * Node:module.exports(UMD),供 fe-tests/unwindBondCalc.test.js 使用。 + * 命名遵《互换价格字段命名规范决策文档》:平仓/了结时点 = Exit(非 End/Close/Final)。 + */ +(function (root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(require('./swapCalc.js')); + } else { + root.UnwindBondCalc = factory(root.SwapCalc); + } +})(typeof self !== 'undefined' ? self : this, function (SwapCalc) { + 'use strict'; + + // driver(回车的"源"字段) → 平仓页浮动腿(floatPosition)字段名 + var FIELDS = { DP: 'TradingAmountAvg', YD: 'ExitYtm' }; + + function hasValue(v) { + return v !== null && v !== undefined && v !== '' && !isNaN(v); + } + + /** + * 构造 /Bond/CalcBond 请求体。 + * ⚠️ price 必须传【展示态百分数】直传(99.5),不做录入页那种 ×100—— + * 平仓页模型里两个字段本就是展示态。此约定由 fe-tests 冻结,防止后人误加换算。 + * source='unwind':BondController 记进日志,便于区分录入页/平仓页来源定位问题。 + */ + function getCalcRequest(underlyingCode, price, driver, valueDate) { + return { + underlyingCode: underlyingCode, + price: Number(price), + priceType: driver, + targetDate: valueDate || null, + source: 'unwind' + }; + } + + /** + * ExitYtm 统一取整:4 位、四舍五入(远离零),对齐后端 decimal ToString("0.0000")。 + * 非数值(null/undefined/NaN)返回 null——供 write 跳过,绝不能把 null 取整成 0 回写 + * (否则计算器未返回 ytm 时会清掉用户手工输入)。 + */ + function roundExitYtm(value) { + if (value === null || value === undefined || isNaN(value)) return null; + return SwapCalc.roundHalfAwayFromZero(value, 4); + } + + /** + * 计算成功回写(交互约定1+2):源字段保持用户手输值,仅回写对方字段(展示态直写)。 + * - driver='DP'(全价回车)→ 回写 ExitYtm = ytm 取整 4 位;计算器未返回 ytm 则不覆盖。 + * - driver='YD'(收益率回车)→ 回写 TradingAmountAvg = dirtyPrice;未返回则不覆盖。 + * ⚠️ 调用方回写 TradingAmountAvg 后须显式重算平仓盈亏(calcFloatClosePnl)。 + * 纯函数,jest 可直接测。 + */ + function applyCalcResult(state, calc, driver) { + function write(field, value) { + if (value === undefined || value === null) return; // 计算器未返回则不覆盖 + var cur = state[field]; + if (typeof cur === 'number' && Math.abs(cur - value) < 1e-9) return; // 无变化不写 + state[field] = value; + } + if (driver === 'DP') { + write('ExitYtm', roundExitYtm(calc && calc.ytm)); + } else if (driver === 'YD') { + write('TradingAmountAvg', calc && calc.dirtyPrice); + } + return state; + } + + /** + * 计算失败落地(交互约定2):保留源字段值(用户可就地改),清对方字段、清全部标识。 + * 与录入页差异:只清"对方"一个字段(录入页清另外两个)。 + */ + function applyCalcFailure(state, driver) { + var other = driver === 'DP' ? 'YD' : 'DP'; + state[FIELDS[other]] = null; + state.bondDriverType = null; + state.bondAuto = { DP: false, YD: false }; + state.bondRev = { DP: false, YD: false }; + return state; + } + + /** 计算成功后的标识(交互约定2):源字段标"源",对方标"AUTO"。 */ + function applyCalcSuccess(state, driver) { + state.bondDriverType = driver; + state.bondAuto = { DP: driver !== 'DP', YD: driver !== 'YD' }; + state.bondRev = { DP: false, YD: false }; + return state; + } + + /** + * 编辑未回车(失焦/按键)的标识(交互约定3):清 源/AUTO,仅本字段累计标"REV", + * 不联动对方字段(允许计算有问题时手工覆盖 ExitYtm)。同录入页:累计 REV 不互清。 + */ + function applyManualEdit(state, type) { + state.bondDriverType = null; + state.bondAuto = { DP: false, YD: false }; + if (!state.bondRev || typeof state.bondRev !== 'object') { + state.bondRev = { DP: false, YD: false }; + } + if (type) state.bondRev[type] = true; + return state; + } + + return { + FIELDS: FIELDS, + hasValue: hasValue, + getCalcRequest: getCalcRequest, + roundExitYtm: roundExitYtm, + applyCalcResult: applyCalcResult, + applyCalcFailure: applyCalcFailure, + applyCalcSuccess: applyCalcSuccess, + applyManualEdit: applyManualEdit + }; +});