docs(swap): 新增互换 MarkClosePnl 口径一致性审计与空头回归红测试(不改生产代码)

- 项目文档/互换计算口径一致性_残留问题与治理路线.md:定义 BUG 本质(分散计算/字段重叠/硬编码精度),
  定位 income 页缺 longRatio 导致空头符号相反的残留点,并给出如何确认 income 错/unwind 对 的裁决链与改动安全性论证。
- YLErpWeb/fe-tests/_proof_short_income.js:纯 Node 红测试原型,证明多头两页一致(掩盖)、空头符号相反、补 longRatio 后一致。
- YLErpWeb/fe-tests/markClosePnlShortConsistency.test.js:jest 格式红测试(与 parity.test.js 同风格),作为后续修复的回归护栏。

说明:仅审计与测试资产,本次未修改任何生产代码(前端页面/后端 C# 均未改动)。
This commit is contained in:
hjhan
2026-08-06 22:00:44 +08:00
parent 01d7f0c55e
commit aaeb4bddf8
3 changed files with 378 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
/**
* _proof_short_income.js — 红测试原型(纯 Node 可跑,无需 jest 依赖)
* ============================================================================
* 目的:证明"income 页 MarkClosePnl 缺 longRatio"是一个真实存在、但当前测试未覆盖的不一致。
*
* 公式逐字抄录自团队金标准 YLErpDAL/Helpers/FrontendCalcReference.cs
* CalcUnwind (unwindSwapTrade.js) : 含 longRatio
* CalcIncome (incomeSwapTrade.js) : 无 longRatio ← 差异点
*
* 跑法:node YLErpWeb/fe-tests/_proof_short_income.js
*/
'use strict';
// ---- unwind 页公式(对应 FrontendCalcReference.CalcUnwind:42-47,含 longRatio----
function calcUnwindMarkClosePnl(input) {
const scale = input.multiplier === 100 ? 0.01 : 1;
const floatRatio = input.payDirection === 1 ? 1 : -1;
const longRatio = input.positionType === 1 ? 1 : -1;
const v = input.closeQty * (input.tradingAmountAvg * scale - input.posiGrossPrice) * floatRatio * longRatio * 10000;
const rounded = Math.round(v) / 10000;
return Math.round(rounded * 100) / 100; // toFixed(2)
}
// ---- income 页公式(对应 FrontendCalcReference.CalcIncome:105-108,无 longRatio----
function calcIncomeMarkClosePnl(input) {
const scale = input.multiplier === 100 ? 0.01 : 1;
const floatRatio = input.payDirection === 1 ? 1 : -1;
// 注意:此处按照当前生产代码,没有乘以 longRatio
const v = input.positionQty * input.contractSize * (input.tradingAmountAvg * scale - input.posiGrossPrice) * floatRatio;
return Math.round(v * 100) / 100;
}
// ---- 修复后 income 公式(补上 longRatio,与 unwind / 后端一致)----
function calcIncomeMarkClosePnlFixed(input) {
const scale = input.multiplier === 100 ? 0.01 : 1;
const floatRatio = input.payDirection === 1 ? 1 : -1;
const longRatio = input.positionType === 1 ? 1 : -1;
const v = input.positionQty * input.contractSize * (input.tradingAmountAvg * scale - input.posiGrossPrice) * floatRatio * longRatio;
return Math.round(v * 100) / 100;
}
// 同一笔债券 TRS:期初全价 1.02,期末(互换/平仓价)105(×100形态→1.05),价差 0.03
const base = { multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, contractSize: 1 };
const longCase = { ...base, closeQty: 10000, positionQty: 10000, payDirection: 1, positionType: 1 };
const shortCase = { ...base, closeQty: 10000, positionQty: 10000, payDirection: 1, positionType: 2 };
function check(name, cond) {
console.log(` [${cond ? 'PASS' : 'FAIL'}] ${name}`);
return cond;
}
console.log('=== 场景A:多头(PositionType=1)—— 两页理应一致 ===');
const aU = calcUnwindMarkClosePnl(longCase);
const aI = calcIncomeMarkClosePnl(longCase);
console.log(` unwind=${aU} income=${aI}`);
let allPass = true;
allPass &= check('多头:unwind == income', aU === aI);
console.log('=== 场景B:空头(PositionType=2)—— 当前代码两页符号相反(红)===');
const bU = calcUnwindMarkClosePnl(shortCase);
const bI = calcIncomeMarkClosePnl(shortCase);
console.log(` unwind=${bU} income=${bI} (空头价格涨应亏损,unwind 正确为负,income 错为正)`);
allPass &= check('空头:unwind == income (当前代码会 FAIL → 证明 bug 存在)', bU === bI);
console.log('=== 场景C:空头 + 修复后 income(补 longRatio)—— 应一致(绿)===');
const bIf = calcIncomeMarkClosePnlFixed(shortCase);
console.log(` unwind=${bU} incomeFixed=${bIf}`);
allPass &= check('空头:unwind == incomeFixed (修复后 PASS → 证明改动可修复)', bU === bIf);
console.log('');
if (allPass) {
console.log('✅ 全部通过(若场景B也PASS,说明已修复或无空头场景)');
} else {
console.log('❌ 场景B 失败 = 当前代码在「空头+income」下两页算出相反符号 → 真实不一致,且现有 FC_001~009 全为多头未覆盖。');
}
@@ -0,0 +1,73 @@
/**
* markClosePnlShortConsistency.test.js — 空头场景下 unwind/income 两页 MarkClosePnl 一致性(红测试)
* ============================================================================
* 状态:当前为 RED(证明 income 页 MarkClosePnl 缺 longRatio 的真实不一致)。
* 修复 incomeSwapTrade.js:207 与 YLErpDAL/Helpers/FrontendCalcReference.CalcIncome:107
* 补上 longRatio 后,本文件应全部转 GREEN。
*
* 背景:
* - 团队金标准 FrontendCalcReference 明确记录两页差异:unwind 含 longRatio
* income 无 longRatioCalcIncome:107,注释"无 longRatio")。
* - 现有特征化测试 FrontendCalcCharacterizationTest FC_001~009 的 income 场景
* FC_006~009)全部 PositionType=1(多头),唯一空头场景 FC_005 是 unwind
* 因此 income 的空头分支从未被覆盖 → bug 长期未被发现。
* - 后端 ValidateFrontendPnL 用同一 CalcIncome 重算比对,公式同源故永远自洽,抓不到。
*
* 公式逐字抄录(来源见注释行号),与生产代码一致;不改动任何生产文件。
*/
const SwapCalc = require('../wwwroot/Scripts/app/swaptrade/swapCalc.js');
// unwind 页 MarkClosePnlunwindSwapTrade.js:313,含 longRatio
function PROD_unwindMarkClosePnl({ closeQty, deliveryPrice, initPosiNetPrice, payDirection, positionType }) {
const floatRatio = payDirection === 1 ? 1 : -1;
const longRatio = positionType === 1 ? 1 : -1;
let v = Math.round(closeQty * (deliveryPrice - initPosiNetPrice) * floatRatio * longRatio * 10000) / 10000;
return Number(v.toFixed(2));
}
// income 页 MarkClosePnlincomeSwapTrade.js:207,无 longRatio
function PROD_incomeMarkClosePnl({ positionAmount, deliveryPrice, initPosiGrossPrice, payDirection }) {
const floatRatio = payDirection === 1 ? 1 : -1;
let v = positionAmount * (deliveryPrice - initPosiGrossPrice) * floatRatio;
return Number(v.toFixed(2));
}
// 金标准(swapCalc.calcMarkClosePnl,对齐 C# FrontendCalcReference.CalcUnwind,含 longRatio
function GOLD({ closeQty, tradingAmountAvg, scale, entryPrice, payDirection, positionType }) {
const floatRatio = payDirection === 1 ? 1 : -1;
const longRatio = positionType === 1 ? 1 : -1;
return SwapCalc.calcMarkClosePnl(closeQty, tradingAmountAvg, scale, entryPrice, floatRatio, longRatio);
}
// 同一笔债券 TRS:期初全价 1.02,期末 105(×100形态→1.05),价差 0.03;数量 10000
const CASE = {
closeQty: 10000, positionAmount: 10000,
deliveryPrice: 105, initPosiNetPrice: 1.02, initPosiGrossPrice: 1.02,
tradingAmountAvg: 105, scale: 0.01, entryPrice: 1.02,
payDirection: 1,
};
describe('MarkClosePnl 两页一致性(多头,应一致)', () => {
test('多头:unwind == income == gold', () => {
const u = PROD_unwindMarkClosePnl({ ...CASE, positionType: 1 });
const i = PROD_incomeMarkClosePnl({ ...CASE, });
const g = GOLD({ ...CASE, positionType: 1 });
expect(u).toBe(i);
expect(i).toBe(g);
});
});
describe('MarkClosePnl 两页一致性(空头,当前 RED)', () => {
test('空头:unwind == income(当前 FAIL → 证明 income 缺 longRatio 的 bug', () => {
const u = PROD_unwindMarkClosePnl({ ...CASE, positionType: 2 });
const i = PROD_incomeMarkClosePnl({ ...CASE, });
// 空头价格涨应亏损:unwind = -300income 当前 = +300(符号反了)
expect(u).toBe(i);
});
test('空头:income 应等于 gold(补 longRatio 后才会 PASS', () => {
const i = PROD_incomeMarkClosePnl({ ...CASE, });
const g = GOLD({ ...CASE, positionType: 2 });
expect(i).toBe(g);
});
});