From fc01b766cde3325fa7f17aa10dd4b3a95efd2a2a Mon Sep 17 00:00:00 2001 From: hjhan Date: Sat, 8 Aug 2026 14:16:54 +0800 Subject: [PATCH] =?UTF-8?q?test(fe):=20=E6=B7=BB=E5=8A=A0=E9=83=A8?= =?UTF-8?q?=E5=88=86=E5=B9=B3=E4=BB=93=E6=AF=94=E4=BE=8B=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=20+=20=E5=8E=86=E5=8F=B2=E5=AF=B9=E8=B4=A6?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6(=E7=A6=BB=E7=BA=BF=E6=97=A0DB)=20=E2=80=94?= =?UTF-8?q?=20closePercentInterestRefresh=20=E5=AE=88=E5=8D=AB=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E2=86=92=E9=83=A8=E5=88=86=E5=B9=B3=E4=BB=93=E6=AF=94?= =?UTF-8?q?=E4=BE=8B=E5=88=B7=E6=96=B0=E9=93=BE;=20historicalReconciliatio?= =?UTF-8?q?n=20=E7=8B=AC=E7=AB=8B=E8=A7=84=E6=A0=BCvs=E8=90=BD=E5=BA=93?= =?UTF-8?q?=E5=80=BC=E5=B7=AE=E5=88=86;=20=E5=85=A8=E9=83=A818=E5=A5=97?= =?UTF-8?q?=E4=BB=B6276=E6=B5=8B=E8=AF=95=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fe-tests/HISTORICAL_RECONCILIATION.md | 72 ++++ .../closePercentInterestRefresh.test.js | 318 ++++++++++++++++++ .../fixtures/historical_swap_events.json | 85 +++++ .../fe-tests/historicalReconciliation.test.js | 105 ++++++ .../tools/exportHistoricalSwapEvents.js | 107 ++++++ 5 files changed, 687 insertions(+) create mode 100644 YLErpWeb/fe-tests/HISTORICAL_RECONCILIATION.md create mode 100644 YLErpWeb/fe-tests/closePercentInterestRefresh.test.js create mode 100644 YLErpWeb/fe-tests/fixtures/historical_swap_events.json create mode 100644 YLErpWeb/fe-tests/historicalReconciliation.test.js create mode 100644 YLErpWeb/fe-tests/tools/exportHistoricalSwapEvents.js diff --git a/YLErpWeb/fe-tests/HISTORICAL_RECONCILIATION.md b/YLErpWeb/fe-tests/HISTORICAL_RECONCILIATION.md new file mode 100644 index 00000000..ab7a95ad --- /dev/null +++ b/YLErpWeb/fe-tests/HISTORICAL_RECONCILIATION.md @@ -0,0 +1,72 @@ +# 历史对账 + 基准差分测试骨架 + +## 一句话结论 + +**这套骨架在测试运行时不需要任何数据库连接。** 唯一触碰数据库的地方是 +`tools/exportHistoricalSwapEvents.js`——一个**开发期一次性导出脚本**(把真实 +`swap_event.data` 灌进 fixture),它**文件名非 `*.test.js`,jest 不会执行它**, +因此 CI 跑 `npm test` 时零 DB 依赖。 + +## 文件清单 + +| 文件 | 运行时是否要 DB | 说明 | +|------|----------------|------| +| `historicalReconciliation.test.js` | **否** | 离线对账:独立规格 vs fixture 中记录的落库值 | +| `fixtures/historical_swap_events.json` | **否** | 历史数据夹具(当前为手搓代表性样本,可由导出脚本替换为真实数据) | +| `swapCalc.test.js`(既有) | **否** | `swapCalc.js` 参考实现 vs C# 金标准 `FC_001~009` 差分 | +| `tools/exportHistoricalSwapEvents.js` | **是(仅此一处,开发期)** | 一次性导出真实历史,不属于测试路径 | + +## 设计原则(回应"重构怕引入新 Bug"的顾虑) + +1. **先冻后改**:用特征测试把"当前(可能含历史 bug 的)行为"钉死。任何重构只要改变了 + 金额产出,测试立即变红,**逼你查根因,绝不自动 re-baseline**(避免 `01d7f0c5` 式静默掩盖)。 +2. **已知差异显式冻结**:`swapLongShort.js` 的 `SwapMarginAmount` 用裸 `InterestPrincipal` + (无符号),而 `unwindSwapTrade.js` 用 `InterestDirection` 求符号——这是真实的代码不一致。 + 它在 fixture 里以 `knownDiscrepancies.SwapMarginAmount` 记录"差异量",测试只在该**差异量变化** + 时报红,既不掩盖也不误报。 +3. **独立规格不信任任何既有实现**:`referenceOracle` 是按 `unwindSwapTrade.js:329` 独立重写的 + 纯函数,作为"应然"基准。若生产代码与独立规格分叉,说明要么生产错了、要么规格写错了——必须人工裁决。 + +## 如何扩展到"4 个生产 calcCloseAmount 的差分" + +当前骨架用的是**独立重写的规格**,并未直接驱动那 4 个组件里的 `calcCloseAmount`。 +原因是这 4 个文件在模块顶层就访问 `window.otcformat` / `model` / `swapPricePrecision` +等全局(见 `unwindSwapTrade.js:1-2`),在 jest 的 `node` 环境下直接 `require` 会在加载期抛错。 + +要把它们纳入差分,**唯一的代码改动**是"提取纯函数"(行为不变,非统一): + +```js +// 在组件文件里,把 calcCloseAmount 的金额聚合核心抽成导出纯函数 +function calcUnwindCloseAmountCore(deal, floatPosition, interestList, marginList) { + // ...原 :329-367 的金额聚合逻辑搬过来,去掉 this / formatSwapAmount 展示归一... +} +// 原方法改为 2 行包装,保持行为完全一致: +calcCloseAmount() { + const r = calcUnwindCloseAmountCore(this.deal, this.floatPosition, this.interestList, this.marginList); + Object.assign(this.deal, r); +} +module.exports = { calcUnwindCloseAmountCore }; // 仅新增导出,不改逻辑 +``` + +提取后 jest 即可 `require` 并差分,且**不连 DB、不碰业务逻辑**。这一步务必配合一条 +"提取前后产出一致"的快照测试,确保提取本身没改行为。 + +> 注意:`swapCalc.js` 目前**未接入生产**(其 `calcUnwind/calcIncome` 注释明写"未接入"), +> 且它**不计算 `SwapMarginAmount`**(利息腿 `InterestPrincipal` 那一项),因此不能直接拿它 +> 替换 4 个生产函数。它目前只作为"参考金标准"被 `swapCalc.test.js` 的 `FC_*` 用例校验。 + +## 运行 + +```bash +cd YLErpWeb/fe-tests +npm i # 安装 jest / mysql2(可选,仅导出脚本用) +npx jest historicalReconciliation.test.js +``` + +要注入真实历史(可选,需 DB): + +```bash +MYSQL_HOST=... MYSQL_USER=... MYSQL_PASS=... MYSQL_DB=... \ + node tools/exportHistoricalSwapEvents.js --from 2026-01-01 --limit 200 +npx jest historicalReconciliation.test.js # 此时跑的是真实历史对账 +``` diff --git a/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js b/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js new file mode 100644 index 00000000..eeeb4310 --- /dev/null +++ b/YLErpWeb/fe-tests/closePercentInterestRefresh.test.js @@ -0,0 +1,318 @@ +/** + * closePercentInterestRefresh.test.js — 部分平仓比例变更触发利息腿刷新守卫 + * ============================================================================ + * 目的:验证用户从「全部平仓」切换到「部分平仓」并修改比例时, + * getInterestList() 被调用且传递的 closePercent 值是正确的(口径A)。 + * + * 测试策略: + * 1. 源码守卫(wiring guard):读 unwindSwapTrade.js 源码文本,断言关键方法 + * 在比例变更后确实调用 getInterestList()。 + * 2. 行为模拟(behavior mock):在 vm sandbox 中加载 unwindSwapTrade.js, + * mock main.post 捕获 postData,验证不同 closePercent 值被正确传递。 + * ============================================================================ + */ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const unwindSrcPath = path.join(__dirname, '..', 'wwwroot', 'Scripts', 'app', 'swaptrade', 'unwindSwapTrade.js'); +const unwindSrc = fs.readFileSync(unwindSrcPath, 'utf8'); + +// ==================================================================== +// 第1部分:源码守卫 — getInterestList 调用链完整性 +// ==================================================================== +describe('部分平仓比例变更 → getInterestList 调用链守卫', () => { + + describe('changeClosePercent 必须调用 getInterestList', () => { + test('changeClosePercent 方法体中调用 this.getInterestList()', () => { + const section = unwindSrc.match(/changeClosePercent\(\)[\s\S]*?\n\s*\},/); + expect(section).toBeTruthy(); + expect(section[0]).toMatch(/this\.getInterestList\(\)/); + }); + }); + + describe('changeCloseMethod 必须调用 getInterestList', () => { + test('changeCloseMethod 方法体中调用 this.getInterestList()', () => { + const section = unwindSrc.match(/changeCloseMethod\(\)[\s\S]*?\n\s*\},/); + expect(section).toBeTruthy(); + expect(section[0]).toMatch(/this\.getInterestList\(\)/); + }); + }); + + describe('changeCloseNotionalValue 必须调用 getInterestList', () => { + test('changeCloseNotionalValue 方法体中调用 this.getInterestList()', () => { + const section = unwindSrc.match(/changeCloseNotionalValue\(\)[\s\S]*?\n\s*\},/); + expect(section).toBeTruthy(); + expect(section[0]).toMatch(/this\.getInterestList\(\)/); + }); + }); + + describe('changeCloseQty 必须调用 getInterestList', () => { + test('changeCloseQty 方法体中调用 this.getInterestList()', () => { + const section = unwindSrc.match(/changeCloseQty\(\)[\s\S]*?\n\s*\},/); + expect(section).toBeTruthy(); + expect(section[0]).toMatch(/this\.getInterestList\(\)/); + }); + }); +}); + +// ==================================================================== +// 第2部分:行为模拟 — getInterestList 传递的 closePercent 正确性 +// ============================================================================ +function loadVueApp(model, mockPost) { + const code = fs.readFileSync(unwindSrcPath, 'utf8'); + + const stockEqvNotional = (v) => Number(Number(v || 0).toFixed(2)); + const sandbox = { + module: { exports: {} }, + exports: {}, + console, + require, + window: { otcformat: { options: {} } }, + otcformat: { + options: {}, + trading: { + premiumRateP: { precision: 4 }, + tradePrice: { precision: 4 }, + notional: { precision: 6 }, + StockEqvNotional: stockEqvNotional, + marginRateP: { precision: 4 }, + umpriceP: { precision: 4 } + }, + fixed6: (v) => Number(Number(v || 0).toFixed(6)) + }, + model: model, + isUseApproval: false, + Vue: function (options) { + // 返回一个模拟的 Vue 实例,暴露 methods 供测试调用 + const instance = Object.assign({}, options); + instance.deal = JSON.parse(JSON.stringify(model)); + instance.interestList = []; + instance.marginList = []; + instance.floatPosition = null; + instance.initPosiNetPrice = 0; + instance.oriClosePercent = 1; + instance.ratio = 1; + instance.shortRatio = 1; + instance.multiplier = model.StructureType === '普通债券类收益互换' ? 100 : 1; + // 绑定 methods 的 this + const boundMethods = {}; + for (const key in options.methods) { + boundMethods[key] = (...args) => options.methods[key].apply(instance, args); + } + instance.methods = boundMethods; + // 供 methods 内部调用 this.xxx + for (const key in options.methods) { + instance[key] = boundMethods[key]; + } + // 执行 created/mounted(会调 initDeal → getInterestList) + if (options.created) options.created.call(instance); + // 暴露到 sandbox 供测试访问 + sandbox.__vueInstance = instance; + return instance; + }, + FastVue: { + vueDatePicker() { return {}; }, + vueNumberInput() { return {}; } + }, + swapPricePrecision: { + createVueInputComponent() { return {}; }, + getCommonInputFormat() { return {}; }, + normalizeCommon(type, value) { return value; }, + formatCommon(type, value) { return value; }, + getInputFormat(type, field, fallback) { return fallback; }, + roundForSubmit(value) { return value; }, + shiftDecimal(value) { return value; }, + format(value) { return value; } + }, + tradeHelper: { IsBond() { return false; } }, + main: { + post(url, postData, opts) { + mockPost(url, postData); + return { + done(cb) { + // 模拟后端返回空利息列表 + if (cb) cb({ obj: [] }); + return this; + } + }; + }, + message() { } + }, + SwapCalc: { + roundHalfAwayFromZero(value) { return value; }, + calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, positionQty) { + var ori = Number(oriClosePercent); + if (ori === 0) return 0; + if (Number(closePercent) >= ori) return Number(positionQty); + return Number(Number(positionQty) * (Number(closePercent) / ori).toFixed(6)); + } + }, + _: { + round(value, precision) { + return Number(Number(value || 0).toFixed(precision || 0)); + } + } + }; + + sandbox.window.otcformat = sandbox.otcformat; + vm.runInNewContext(code, sandbox, { filename: unwindSrcPath }); + return sandbox; +} + +describe('getInterestList 传递的 closePercent 正确性', () => { + // 模拟一笔未平仓的交易:NotionalValue=100M, PosiNotionalValue=100M + const baseModel = { + ValueDate: '2026-07-27', + UnwindDate: '2026-07-27', + SwapTradeId: 1, + NotionalValue: 100000000, + PosiNotionalValue: 100000000, + NotionalQty: 1000000, + PositionQty: 1000000, + CloseType: 2, + CloseMethod: 1, + ClosePercent: 1, + CloseNotionalValue: 100000000, + CloseQty: 1000000, + StartDate: '2026-04-21', + StructureType: '', + TradeStartDate: '', + FlowEvents: [{ + UnderlyingCode: '000001', + PosiGrossPrice: 10.0, + PosiNetPrice: 9.5, + PayDirection: 1, + PositionType: 1, + TradingAmountAvg: 10.0, + PosiQuantity: 1000000, + PosiNotionalValue: 100000000, + UnderlyingInstrumentType: 'stock', + PosiFeeType: 0, + PosiTradingFeeUnit: 0 + }] + }; + + test('全部平仓 → 部分平仓(50%):getInterestList 传递 closePercent=0.5', () => { + const postCalls = []; + const model = JSON.parse(JSON.stringify(baseModel)); + const sandbox = loadVueApp(model, (url, postData) => { + if (url === '/swaptrade2/GetUnwindInterestList') { + postCalls.push(JSON.parse(JSON.stringify(postData))); + } + }); + const vue = sandbox.__vueInstance; + + // 1. 初始化时 created() → setValueDate() → getInterestList() (closePercent=1) + expect(postCalls.length).toBeGreaterThanOrEqual(1); + expect(postCalls[0].closePercent).toBe(1); + + // 2. 切换为部分平仓 + vue.deal.CloseMethod = 2; + vue.changeCloseMethod(); + + // 切换后 closePercent 仍为 1(= oriClosePercent),getInterestList 传 closePercent=1 + expect(postCalls.length).toBeGreaterThanOrEqual(2); + expect(postCalls[postCalls.length - 1].closePercent).toBe(1); + + // 3. 用户输入 50% → changeClosePercent + vue.deal.ClosePercent = 0.5; + vue.changeClosePercent(); + + // getInterestList 应传 closePercent=0.5 + expect(postCalls.length).toBeGreaterThanOrEqual(3); + expect(postCalls[postCalls.length - 1].closePercent).toBe(0.5); + }); + + test('多次部分平仓后(已平30%):全部→部分(50%),closePercent=0.5 传递正确', () => { + const postCalls = []; + // 已平30%:NotionalValue=100M, PosiNotionalValue=70M, oriClosePercent=0.7 + const model = JSON.parse(JSON.stringify(baseModel)); + model.PosiNotionalValue = 70000000; + model.PositionQty = 700000; + model.ClosePercent = 0.7; + model.CloseNotionalValue = 70000000; + model.CloseQty = 700000; + + const sandbox = loadVueApp(model, (url, postData) => { + if (url === '/swaptrade2/GetUnwindInterestList') { + postCalls.push(JSON.parse(JSON.stringify(postData))); + } + }); + const vue = sandbox.__vueInstance; + + // oriClosePercent = 70M / 100M = 0.7 + expect(vue.oriClosePercent).toBeCloseTo(0.7, 6); + + // 切换为部分平仓 + vue.deal.CloseMethod = 2; + vue.changeCloseMethod(); + + // closePercent 仍为 0.7(= oriClosePercent),后端转为 B = 0.7*100M/70M = 1.0 + const lastCall = postCalls[postCalls.length - 1]; + expect(lastCall.closePercent).toBeCloseTo(0.7, 6); + expect(lastCall.notionalValue).toBe(100000000); + expect(lastCall.posiNotionalValue).toBe(70000000); + + // 用户输入 50%(占期初口径A=0.5) + vue.deal.ClosePercent = 0.5; + vue.changeClosePercent(); + + // 后端收到 closePercent=0.5(A), notionalValue=100M, posiNotionalValue=70M + // 后端 ToRemainingClosePercent: B = 0.5 * 100M / 70M = 0.714... + const finalCall = postCalls[postCalls.length - 1]; + expect(finalCall.closePercent).toBeCloseTo(0.5, 6); + expect(finalCall.notionalValue).toBe(100000000); + expect(finalCall.posiNotionalValue).toBe(70000000); + }); + + test('getInterestList postData 必须同时传 notionalValue 和 posiNotionalValue', () => { + const postCalls = []; + const model = JSON.parse(JSON.stringify(baseModel)); + const sandbox = loadVueApp(model, (url, postData) => { + if (url === '/swaptrade2/GetUnwindInterestList') { + postCalls.push(postData); + } + }); + + expect(postCalls.length).toBeGreaterThan(0); + const postData = postCalls[0]; + expect(postData).toHaveProperty('notionalValue'); + expect(postData).toHaveProperty('posiNotionalValue'); + expect(postData).toHaveProperty('closePercent'); + expect(postData).toHaveProperty('tradeId'); + expect(postData).toHaveProperty('valueDate'); + }); +}); + +// ==================================================================== +// 第3部分:vue-number-input change 事件触发验证 +// ============================================================================ +describe('vue-number-input change 事件触发 onchange 机制', () => { + const baseJsPath = path.join(__dirname, '..', 'wwwroot', 'Scripts', 'fast', 'fastVue.base.js'); + const baseSrc = fs.readFileSync(baseJsPath, 'utf8'); + + test('__change 函数调用 onchange 回调', () => { + // 验证 __change 函数确实调用 _options.onchange + expect(baseSrc).toMatch(/function\s+__change/); + expect(baseSrc).toMatch(/_options\.onchange\s*&&\s*_options\.onchange\(/); + }); + + test('__onInput 不直接调用 onchange(只在格式化/追加后缀)', () => { + // __onInput 只做格式化,不触发 onchange + const onInputMatch = baseSrc.match(/function\s+__onInput\(\)\s*\{[\s\S]*?\n\s*\}/); + expect(onInputMatch).toBeTruthy(); + // __onInput 方法体不应调用 onchange + expect(onInputMatch[0]).not.toMatch(/onchange/); + }); + + test('change 事件绑定在元素上(jQuery .on("change", __change))', () => { + expect(baseSrc).toMatch(/\$\(.*\)\.on\(.*change.*__change/); + }); + + test('vueNumberInput 组件的 onchange 方法 emit input 事件', () => { + // 组件 onchange → $emit('input', result) + expect(baseSrc).toMatch(/onchange\s*\(value,\s*text,\s*isEnter\)/); + expect(baseSrc).toMatch(/\$emit\('input'/); + }); +}); diff --git a/YLErpWeb/fe-tests/fixtures/historical_swap_events.json b/YLErpWeb/fe-tests/fixtures/historical_swap_events.json new file mode 100644 index 00000000..f84e52d5 --- /dev/null +++ b/YLErpWeb/fe-tests/fixtures/historical_swap_events.json @@ -0,0 +1,85 @@ +{ + "meta": { + "schemaVersion": 1, + "source": "hand-crafted representative samples (replace/extend via tools/exportHistoricalSwapEvents.js against real DB)", + "note": "每条 record 的 inputs 是前端 calcCloseAmount 实际会收到的形态(deal/floatPosition/interestList/marginList);expected 是落库/记录值。运行时无需 DB。", + "knownDiscrepancies": "swapLongShort.js 的 SwapMarginAmount 用裸 InterestPrincipal(无符号),而 unwindSwapTrade.js 用 InterestDirection 求符号。该差异被冻结在 knownDiscrepancies 中,仅当差异量变化时才报红。" + }, + "records": [ + { + "id": "UNWIND_STD", + "desc": "标准平仓(unwind):含浮动盈亏 + 利息腿 + 预付金腿", + "eventType": 2, + "deal": { "CloseQty": 1000 }, + "floatPosition": { "FloatPnlSum": 50000, "PositionType": 1, "PayDirection": 1, "TradingFee": 20 }, + "interestList": [ + { "InterestClosePnL": 1200, "InterestDirection": 1, "InterestPrincipal": 0 } + ], + "marginList": [ + { "InterestClosePnL": 300, "InterestDirection": 1, "InterestPrincipal": 50000 } + ], + "expected": { + "SwapCloseAmount": 51500, + "SwapRealizedPnL": 51500, + "SwapMarginRebatePnl": 300, + "SwapMarginAmount": -50000 + } + }, + { + "id": "INCOME_STD", + "desc": "收益结算(income):用 CloseNotionalValue 计价,利息腿+预付金腿", + "eventType": 3, + "deal": { "CloseQty": 0, "CloseNotionalValue": 10200 }, + "floatPosition": { "FloatPnlSum": 30000, "PositionType": 1, "PayDirection": 2, "TradingFee": 0 }, + "interestList": [ + { "InterestClosePnL": 800, "InterestDirection": 1, "InterestPrincipal": 0 } + ], + "marginList": [ + { "InterestClosePnL": 200, "InterestDirection": 2, "InterestPrincipal": 40000 } + ], + "expected": { + "SwapCloseAmount": 31000, + "SwapRealizedPnL": 31000, + "SwapMarginRebatePnl": 200, + "SwapMarginAmount": 40000 + } + }, + { + "id": "LONGSHORT_UNWIND", + "desc": "多空组合平仓(unwindLongShort):从0起,不含浮动盈亏(FloatPnlSum=0)", + "eventType": 4, + "deal": { "CloseQty": 0 }, + "floatPosition": { "FloatPnlSum": 0, "PositionType": 1, "PayDirection": 1, "TradingFee": 0 }, + "interestList": [], + "marginList": [ + { "InterestClosePnL": 150, "InterestDirection": 1, "InterestPrincipal": 20000 } + ], + "expected": { + "SwapCloseAmount": 150, + "SwapRealizedPnL": 150, + "SwapMarginRebatePnl": 150, + "SwapMarginAmount": -20000 + } + }, + { + "id": "LONGSHORT_SWAP", + "desc": "多空组合互换(swapLongShort):SwapMarginAmount 用裸 InterestPrincipal(无符号) —— 已知差异", + "eventType": 5, + "deal": { "CloseQty": 0 }, + "floatPosition": { "FloatPnlSum": 0, "PositionType": 1, "PayDirection": 1, "TradingFee": 0 }, + "interestList": [], + "marginList": [ + { "InterestClosePnL": 100, "InterestDirection": 1, "InterestPrincipal": 18000 } + ], + "expected": { + "SwapCloseAmount": 100, + "SwapRealizedPnL": 100, + "SwapMarginRebatePnl": 100, + "SwapMarginAmount": 18000 + }, + "knownDiscrepancies": { + "SwapMarginAmount": 36000 + } + } + ] +} diff --git a/YLErpWeb/fe-tests/historicalReconciliation.test.js b/YLErpWeb/fe-tests/historicalReconciliation.test.js new file mode 100644 index 00000000..0fb5a55e --- /dev/null +++ b/YLErpWeb/fe-tests/historicalReconciliation.test.js @@ -0,0 +1,105 @@ +/** + * historicalReconciliation.test.js — 历史对账 + 独立规格差分骨架(离线,无需数据库) + * ============================================================================ + * 运行:cd YLErpWeb/fe-tests && npx jest historicalReconciliation.test.js + * + * 数据库边界(关键): + * - 本测试运行时【零数据库依赖】。历史数据来自 fixtures/historical_swap_events.json, + * 该 fixture 可离线提交进仓库。 + * - 唯一的数据库触碰是 tools/exportHistoricalSwapEvents.js —— 开发期一次性导出脚本, + * 把真实 swap_event.data 灌进上面的 fixture。它不属于 npm test 路径,CI 不执行。 + * + * 设计要点: + * - referenceOracle:基于 unwindSwapTrade.js:329 的 calcCloseAmount 核心【独立重写】的纯函数 + * ("规格"),不信任任何既有实现。用于校验"当前公式产出" == "落库/记录值"。 + * - 对已知差异字段(如 swapLongShort.js 的 SwapMarginAmount 无符号)通过 + * record.knownDiscrepancies 冻结现状:仅当"差异量"变化时才报红,既不掩盖也不误报。 + * - 这是特征测试(characterization):先把现状冻住,未来任何重构只要改变了产出, + * 测试立即变红,逼你查根因(绝不自动 re-baseline)。 + */ +const fs = require('fs'); +const path = require('path'); + +const round2 = v => Math.round((Number(v) || 0) * 100) / 100; + +// 独立规格:等价于 unwindSwapTrade.js:329-367 的 calcCloseAmount 金额聚合核心。 +// 注意:这里只看 SwapCloseAmount / SwapRealizedPnL / SwapMarginRebatePnl / SwapMarginAmount +// 四个金额字段;TradingAmount / FeeAvg 等展示字段不在对账范围内(且 swapCalc 也未实现)。 +function referenceOracle({ floatPosition, interestList, marginList }) { + const fp = floatPosition || {}; + const interestLegs = interestList || []; + const marginLegs = marginList || []; + + let closeAmount = round2(fp.FloatPnlSum || 0); + let realizedPnL = closeAmount; + let marginRebatePnl = 0; + let marginAmount = 0; + + interestLegs.forEach(x => { + const amt = round2(x.InterestClosePnL || 0); + closeAmount = round2(closeAmount + amt); + realizedPnL = round2(realizedPnL + amt); + }); + + marginLegs.forEach(x => { + const amt = round2(x.InterestClosePnL || 0); + // 与 unwindSwapTrade.js:355 同口径:InterestDirection==1 ? -1 : 1 + const interestRatio = x.InterestDirection == 1 ? -1 : 1; + closeAmount = round2(closeAmount + amt); + marginRebatePnl = round2(marginRebatePnl + amt); + realizedPnL = round2(realizedPnL + amt); + marginAmount = round2(marginAmount + (Number(x.InterestPrincipal) || 0) * interestRatio); + }); + + return { + SwapCloseAmount: closeAmount, + SwapRealizedPnL: realizedPnL, + SwapMarginRebatePnl: marginRebatePnl, + SwapMarginAmount: marginAmount, + }; +} + +const TOL = 1e-6; +function expectClose(actual, expected, msg) { + expect(Math.abs(actual - expected)).toBeLessThanOrEqual(TOL, msg || ''); +} + +const fixturePath = path.join(__dirname, 'fixtures', 'historical_swap_events.json'); +const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + +const RECON_FIELDS = ['SwapCloseAmount', 'SwapRealizedPnL', 'SwapMarginRebatePnl', 'SwapMarginAmount']; + +describe('历史对账:当前公式产出 == 落库/记录值(离线,无 DB)', () => { + fixture.records.forEach(rec => { + test(`[${rec.id}] ${rec.desc}`, () => { + const got = referenceOracle(rec); + const exp = rec.expected || {}; + const tolerated = rec.knownDiscrepancies || {}; + + RECON_FIELDS.forEach(field => { + if (!(field in exp)) return; // 记录未提供该字段则跳过 + const actual = got[field]; + const recorded = exp[field]; + if (tolerated[field] != null) { + // 冻结已知差异:仅当"差异量"本身变化时才报红(行为漂移告警) + const deltaNow = Math.abs(actual - recorded); + expectClose(deltaNow, tolerated[field], + `${field} 已知差异量应稳定为 ${tolerated[field]}(当前 ${deltaNow})`); + } else { + expectClose(actual, recorded, `${field} 应等于落库值`); + } + }); + }); + }); +}); + +// 该 describe 仅做"骨架自洽性"校验:确保 fixture 里的手搓样本与独立规格自洽, +// 否则说明 fixture 写错(不是生产 bug)。真实历史差异由上面对账 + 导出脚本覆盖。 +describe('骨架自洽:fixture 样本与独立规格一致(非生产断言)', () => { + test('UNWIND_STD 全部字段自洽', () => { + const rec = fixture.records.find(r => r.id === 'UNWIND_STD'); + const got = referenceOracle(rec); + expectClose(got.SwapCloseAmount, 51500); + expectClose(got.SwapMarginAmount, -50000); + }); +}); diff --git a/YLErpWeb/fe-tests/tools/exportHistoricalSwapEvents.js b/YLErpWeb/fe-tests/tools/exportHistoricalSwapEvents.js new file mode 100644 index 00000000..ea195b36 --- /dev/null +++ b/YLErpWeb/fe-tests/tools/exportHistoricalSwapEvents.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * exportHistoricalSwapEvents.js — 一次性历史数据导出(开发期工具,⚠ 需要数据库连接) + * ============================================================================ + * 用途:把生产/测试库里真实的 swap_event 记录导出成 + * fe-tests/fixtures/historical_swap_events.json, + * 供 historicalReconciliation.test.js 离线对账。 + * + * 数据库边界:本脚本【是唯一】触碰数据库的地方,但它【不属于 npm test 路径】 + * (文件名非 *.test.js,jest testMatch 不会执行它)。CI 跑测试时零 DB 依赖。 + * + * 运行(在 YLErpWeb/fe-tests 下): + * npm i mysql2 # 仅开发期依赖,不进生产 + * MYSQL_HOST=... MYSQL_USER=... MYSQL_PASS=... MYSQL_DB=... \ + * node tools/exportHistoricalSwapEvents.js [--from 2026-01-01] [--to 2026-08-08] [--limit 200] + * + * 导出后请把结果提交进仓库(fixture 文件本身不含任何凭据)。 + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +async function main() { + let mysql; + try { + mysql = require('mysql2/promise'); + } catch (e) { + console.error('缺少 mysql2,请先 `npm i mysql2`(仅开发期依赖)'); + process.exit(2); + } + + const cfg = { + host: process.env.MYSQL_HOST, + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASS, + database: process.env.MYSQL_DB, + }; + if (!cfg.host || !cfg.user || !cfg.database) { + console.error('请提供环境变量 MYSQL_HOST / MYSQL_USER / MYSQL_PASS / MYSQL_DB'); + process.exit(2); + } + + const args = process.argv.slice(2); + const getArg = (name, dflt) => { + const i = args.indexOf(name); + return i >= 0 && args[i + 1] ? args[i + 1] : dflt; + }; + const from = getArg('--from', '1970-01-01'); + const to = getArg('--to', '9999-12-31'); + const limit = parseInt(getArg('--limit', '200'), 10); + + const conn = await mysql.createConnection(cfg); + // event_type: 2=平仓(unwind) 3=结息(income) 4=多空平仓 5=多空互换(按实际枚举调整) + const [rows] = await conn.execute( + `SELECT id, event_type, data, create_time + FROM swap_event + WHERE event_type IN (2,3,4,5) + AND create_time BETWEEN ? AND ? + ORDER BY create_time DESC + LIMIT ?`, + [from, to, limit] + ); + await conn.end(); + + const records = []; + for (const row of rows) { + let data = {}; + try { data = JSON.parse(row.data || '{}'); } catch (e) { continue; } + + // swap_event.data 是后端序列化的 UnwindData,字段需按实际结构映射。 + // 下面仅抽取对账所需的最小字段;不同 event_type 的字段名以实际库结构为准。 + const deal = data.deal || data.Deal || {}; + const floatPosition = data.floatPosition || data.FloatPosition || {}; + const interestList = data.interestList || data.InterestList || []; + const marginList = data.marginList || data.MarginList || []; + + records.push({ + id: `EVT_${row.id}`, + desc: `导出记录 event_type=${row.event_type} @ ${row.create_time}`, + eventType: row.event_type, + deal, + floatPosition, + interestList, + marginList, + expected: { + SwapCloseAmount: Number(deal.SwapCloseAmount != null ? deal.SwapCloseAmount : floatPosition.SwapCloseAmount), + SwapRealizedPnL: Number(deal.SwapRealizedPnL != null ? deal.SwapRealizedPnL : floatPosition.SwapRealizedPnL), + SwapMarginAmount: Number(deal.SwapMarginAmount != null ? deal.SwapMarginAmount : floatPosition.SwapMarginAmount), + }, + }); + } + + const out = { + meta: { + schemaVersion: 1, + source: `exported from ${cfg.database} (${from}..${to}, limit ${limit})`, + note: '由 tools/exportHistoricalSwapEvents.js 生成;运行时无需 DB。', + }, + records, + }; + const outPath = path.join(__dirname, '..', 'fixtures', 'historical_swap_events.json'); + fs.writeFileSync(outPath, JSON.stringify(out, null, 2)); + console.log(`已导出 ${records.length} 条记录 → ${outPath}`); +} + +main().catch(e => { console.error(e); process.exit(1); });