test(fe): 添加部分平仓比例刷新守卫 + 历史对账骨架(离线无DB) — closePercentInterestRefresh 守卫全部→部分平仓比例刷新链; historicalReconciliation 独立规格vs落库值差分; 全部18套件276测试通过
This commit is contained in:
@@ -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 # 此时跑的是真实历史对账
|
||||
```
|
||||
@@ -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'/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user