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);
});
});
@@ -0,0 +1,230 @@
# 互换(TRS)计算口径一致性:残留问题与治理路线
> 配套文档:`互换分红损益字段语义与重复计算分析.md`(2026-06,根因分析)
> 本次分析目的:在最新提交 `e3c473ba`(分红收益改由 EOD 单一可信源)之后,
> 复核"分散计算 / 前后端重复算 / 精度口径不统一"这类 BUG 是否仍在其它地方存在,
> 并给出"如何避免 + 后续逐渐解决"的路线。
> 分析日期:2026-08-06
---
## 一、这类 BUG 的本质(统一定义)
最新修复的"分红收益误显 -36,160",根因不是某一个 if 写错,而是一种**结构性缺陷**:
> **金融计算缺乏「单一可信源 / 统一口径」** —— 同一个业务量在
> **前端 JS / 后端 C# / EOD 批处理** 三条路径里被**各自重算**,
> 且价格基准、方向符号、精度位数都零散硬编码,导致:
> 1. 前后端(或同一功能的两页)对同一量算出**不同值**;
> 2. 分红等分量被**重复计入**MarkClosePnl 含分红 → 汇总翻倍);
> 3. 金额/价格**精度不统一**,相邻环节差 1~2 位小数。
它表现为 3 个子模式:
| 子模式 | 例子 | 状态 |
|--------|------|------|
| ① 分散计算、无单一可信源 | 分红前端自算(`getDivindIn`vs EOD `PosiDividendSum` | **最新提交已根治(方案C** |
| ② 字段语义重叠→重复累加 | `MarkClosePnl` 含分红,汇总 `RealizedPnl` 翻倍 | 自动路径已按方向A改造;**前端两页仍有口径差** |
| ③ 硬编码精度/魔法数字 | `Math.Round(...,4/10)``ToString("F10")` vs `ConsGlobal.*` | **大量残留** |
---
## 二、最新提交(e3c473ba)做了什么(已修复)
- 把"浮动端平仓盈亏·分红 `DividendIn`"从**前端 `getDivindIn` 自算**`期初持仓×totalInterest`
改为**后端 `SwapDealService.GetPreEodDividendSum` 读 EOD `PosiDividendSum`**(单一可信源)。
- 前端 `unwindSwapTrade.js` / `incomeSwapTrade.js``getDivindIn` 删除自算逻辑,只保留后端值。
- `GetBondPayMentInterest` 标记废弃(保留接口供历史调用)。
- 配套绿灯验收测试 `GLMS20260105PartialCloseDividendBugTest.cs`
**结论**:子模式①(分红分散计算)在"预览页展示"这条链路已根治。但**②④③ 仍在**。
---
## 三、同类问题在别处是否还存在(带 file:line 证据)
### A. 前端两页 `MarkClosePnl` 口径不一致 —— 最危险、最具体的残留 ⚠️
> **2026-08-06 修正**:初版称"平仓页用净价、互换页用全价"——经核对代码**不准确**。
> 实际两页都用**全价**`unwindSwapTrade.js:111` 把 `initPosiNetPrice` 绑到 `PosiGrossPrice`(变量名有误导性),
> `incomeSwapTrade.js:103/207` 用 `initPosiGrossPrice`(也是全价)。真正差异见下表与 A.1。
同一字段 `MarkClosePnl`(盯市盈亏),实现对照(来源:团队金标准 `YLErpDAL/Helpers/FrontendCalcReference.cs`):
| 实现 | 位置 | 公式 | 价格基准 | 方向 | 数量基准 |
|------|------|------|----------|------|----------|
| 平仓页 | `unwindSwapTrade.js:313` | `CloseQty × (价 期初全价) × floatRatio × longRatio` | 全价(变量名骗人) | 含 `longRatio` | `CloseQty` |
| 互换页 | `incomeSwapTrade.js:207` | `positionAmount × (价 期初全价) × floatRatio` | 全价 | **无 `longRatio`** | `positionAmount=PositionQty×ContractSize` |
| 后端 | `SwapDealService.cs:1613` | `(价 PosiGrossPrice) × unwindQty × floatRatio × longRatio` | 全价 | 含 `longRatio` | `unwindQty` |
| 金标准 | `FrontendCalcReference.CalcUnwind:44` / `CalcIncome:107` | 同上(`CalcIncome` 注释明示"无 longRatio" | 全价 | unwind 有 / income 无 | — |
**真正的风险点(已用红测试证明,见 A.1):**
1. **互换页缺 `longRatio`(多空方向)** —— 这是实打实的 bug。一旦 `PositionType=2`(空头),
unwind 页与后端会乘 `1`,而 income 页不乘 → **同一笔空头两页算出相反符号**
债券 TRS(国联民生等)普遍支持空头,并非边缘场景。
2. **数量基准不同**(设计使然,非 bug):平仓页用 `CloseQty`(本次平仓量),互换页用 `PositionQty×ContractSize`
(剩余持仓量,因互换是全额置换剩余持仓)。语义不同但各自自洽,需业务确认是否期望一致。
3. **中间取整**income 无 `×10000/10000` 步骤(`FrontendCalcReference` 注释明示),但末端都保留 2 位,
干净输入下等价(parity 已证明),属低风险。
**共享的 `swapCalc.calcMarkClosePnl``swapCalc.js:113`,全价+`longRatio`+`scale`)已存在,
但两个生产页面都没调用**——仍是"单一可信源已有却不采纳"。
**会进库吗?** 会。手动平仓/互换的 `MarkClosePnl` 由前端算好传入,后端**直接存库不重算**
`SwapDealService:1500/1991``ValidateFrontendPnL` 仅"只读告警、不阻断")。
因此 income 页空头符号错误会直接落到 `swap_flow_event.MarkClosePnl`,并带偏 `FloatPnlSum`/`SwapRealizedPnL`
### A.1 为什么一直没暴露?—— 红测试证据(2026-08-06 补)
三层叠加导致这个 bug 长期潜伏:
1. **校验同源、永远自洽**`ValidateFrontendPnL``FrontendCalcReference.CalcIncome` 重算比对,
`CalcIncome` 本身就没 `longRatio`,所以"被校验的前端"和"校验用的公式"完全一致,永远不告警。
2. **特征化测试全是多头**`FrontendCalcCharacterizationTest` 的 income 场景 `FC_006~009`
**全部 `PositionType=1`(多头)**;唯一空头场景 `FC_005` 是 unwind。income 的空头分支从未被触发。
3. **告警不阻断** + 生产数据里"空头做互换"相对少见,进一步降低暴露概率。
**已落地红测试(证明 bug 真实存在 + 证明改动可修复):**
- `YLErpWeb/fe-tests/markClosePnlShortConsistency.test.js`jest,与 `parity.test.js` 同风格)
- `YLErpWeb/fe-tests/_proof_short_income.js`(纯 Node 可跑,无需依赖)
- 运行结果:多头场景两页一致(PASS,掩盖了问题);**空头场景 `unwind=300` / `income=+300`FAIL,符号相反)**
给 income 补 `longRatio``income=300` 与 unwind 一致(PASS)。
→ 既证明"当前代码对空头不一致(有问题)",也证明"给 income 补 `longRatio` 即可修复"。
### B. 后端硬编码精度魔法数字 —— 与集中常量冲突/不绑定的残留 ⚠️
集中常量(`Framework/YLErp.Core/ConsGlobal.cs`):`PriceRound=11``SwapDeliveryPriceRound=9`
`MoneyRound=2`;模块内 `InterestCalculationPrecision=12``EodInterestStoragePrecision=12`
残留的裸数字(不引用上述常量):
| 位置 | 写法 | 应参照 | 问题 |
|------|------|--------|------|
| `SwapFlowService.cs:116-118` | `Math.Round(price, 4)`ClosePrice/SettlePrice/ReferencePrice | `PriceRound=11` / `SwapDeliveryPriceRound=9` | 价格存储 4 位 vs 全系统 9~11 位,**口径不一致** |
| `SwapDealService.cs:1543` | `unwindPriceFee.ToString("F10")` | — | 硬编码 10 位 |
| `SwapTradeAutoService.cs:458/460` | `Math.Round(..., 10)`(净均价) | `PriceRound=11` | 差 1 位 |
| `SwapTradeAutoService.cs:788/1409` | `Math.Round(..., 4)`(费用/平仓费) | `MoneyRound=2` | **费用 4 位 vs 全系统金额 2 位,错配** |
| 分红链路多处 `Math.Round(...,2)``SwapEodPositionService.cs:1647/1711/1713/2003``SwapDealService.cs:1658/1882/1883` | 裸 `2` | `ConsGlobal.MoneyRound` | 目前恰等于 2,但属硬编码,`MoneyRound` 一旦配置化即失真 |
说明:第 4 行(费用 4 位 vs 金额 2 位)是**真实精度错配**,不是巧合一致;其余价格/净均价是"差 1 位"的隐患。
### C. 交叉校验基础设施已建,但未"落地到生产" —— 治理杠杆闲置
- `swapCalc.js``calcUnwind` / `calcIncome``:135`/`:170`)是**"参考规格,未接入生产代码"**——
注释明确写着生产 Vue 只调用 4 个叶子函数,聚合逻辑仍是各页内联。
- `fe-tests/parity.test.js` + `swapCalc.test.js` 已能冻结 8 个 FC 场景,但只覆盖已迁移的 4 个叶子函数。
- 这正是"避免反复打补丁"的关键设施,**却没把生产聚合逻辑迁过去**。
### D. 横向同类风险(建议扫描,本次未深入)
互换之外的 期货 / 期权 / 定价引擎(greeks)/ 估值报告 等模块,
很可能也存在"前端重算 + 后端重算""硬编码精度"的同构问题,需专项扫描。
---
## 四、如何避免(规范 / 治理层)
1. **单一可信源原则(最高优先级)**
- 每个金融量只允许**一个权威计算点**:分红→EOD `PosiDividendSum`;盯市→`swapCalc.calcMarkClosePnl`
金额聚合→`swapCalc.calcFloatPnlSum` / `calcUnwind` / `calcIncome`
- 前端**只展示、不重算**;手动平仓/互换落库时由后端**重算**而非信任前端传值。
2. **精度集中化(红线)**
- 任何 `Math.Round` / `ToString("F")` 必须引用 `ConsGlobal.*``GetStorageDeliveryPriceRound(underlyingInstrumentType, code)`
**禁止裸数字**。代码评审把"裸精度数字"列为 blocking 项。
3. **字段语义单一职责(已定义,需固化)**
- `MarkClosePnl`=纯价差盯市、`DividendIn`=纯分红、`RealizedPnl`=不重叠分量之和。
- 写进评审清单 + 用集成测试守护。
4. **自动守护测试(把已建设施用起来)**
- 扩展 `parity.test.js` 的"生产表达式逐字抄录 vs `SwapCalc`"模式到**所有关键公式**;
- 后端补"分红守恒 / 持仓守恒"集成测试:`ΔSwapPositionValue + ΔRealizedPnl == 0`
(参考分析文档附录 A.2 的 SQL 思路,转成 `SwapEodPositionServiceIntegrationTest` 断言)。
5. **防回归**:将 `swapCalc.calcUnwind/calcIncome` 接入生产,删除两页内联实现。
---
## 五、后续逐渐解决的路线(分阶段、低风险)
- **阶段 0(已具备)**`parity.test.js` / `swapCalc.test.js` / `GLMS20260105PartialCloseDividendBugTest.cs` 框架。
- **阶段 1(低风险、先消除最危险口径)**:
`incomeSwapTrade.js:207``unwindSwapTrade.js:313` 统一调用 `swapCalc.calcMarkClosePnl`
(全价 + `longRatio` + `scale`),并确认与后端 `PosiGrossPrice` 口径一致;
新增"净价≠全价""空头 longRatio"两个 parity 场景守护。**改动小、收益高。**
- **阶段 2(消除魔法数字)**
`SwapFlowService` / `SwapTradeAutoService` / `SwapDealService` 里的裸 `Math.Round(...,4/10)`
`ToString("F10")` 替换为 `ConsGlobal.*` / `GetStorageDeliveryPriceRound`;分红的裸 `2` 改为 `ConsGlobal.MoneyRound`
逐文件改,每改一处跑 parity + 现有单测。
- **阶段 3(后端收口)**
手动平仓/互换落库时,后端对 `MarkClosePnl` / `FloatPnlSum` / `SwapRealizedPnL` **重算**(与 `swapCalc` 金标准一致),
不再信任前端;同时跑"持仓守恒"SQL 校验历史数据是否已被两页口径差污染。
- **阶段 4(横向扫描)**
用脚本/Explore 扫描 期货、期权、定价引擎、估值报告 模块,查找"前端重算+后端重算""硬编码精度"同类结构,建立清单逐个治理。
- **阶段 5(文档固化)**
更新 `互换分红损益字段语义与重复计算分析.md`,把"已修复 / 待修复"状态机化,作为新人评审清单与回归基线。
---
## 六、一句话结论
> 最新修复根治了"分红预览"这一条链路的分散计算;但**同类结构依然存在**——
> 前端平仓页/互换页的 `MarkClosePnl` 都用全价,真正的差异是**互换页(income)漏乘 `longRatio`(多空方向)**
> 对空头会算出相反符号并直接落库(后端对 income 不重算、原样存前端值);共享的 `swapCalc.calcMarkClosePnl` 两个页面都没用;
> 后端仍有**费用 4 位 vs 金额 2 位**等硬编码精度错配。
> 治理的关键不是再打补丁,而是**把已建好的 `swapCalc` 单一可信源 + parity 守护真正接入生产**
> 并按上述 5 个阶段低风险推进。
---
## 七、如何确认"income 错 / unwind 对"(而非相反)+ 改动安全性
> 这一章回答一个关键质疑:两页口径不同,凭什么断定是 income 漏了 `longRatio`、而不是 unwind 多算了?
> 以及:给 income 补 `longRatio` 会不会把正确逻辑改坏、或造成"双重翻转"?
### 7.1 两个方向乘子是**独立轴**(这是避免误判的前提)
- `floatRatio = PayDirection==1(收取) ? +1 : -1` —— 跟随**收付方向**`FrontendCalcReference.cs:35`
- `longRatio = PositionType==1(多头) ? +1 : -1` —— 跟随**多空方向**`FrontendCalcReference.cs:36`
二者在本系统里**互不决定**:一个"空头"完全可以 `PayDirection=收取`
### 7.2 裁决性证据:FC_005(空头平仓)冻结基线
`UnitTestProject/.../FrontendCalcCharacterizationTest.cs``FC_005`
```
输入:PositionType=2(空头) + PayDirection=1(收取)
注释:floatRatio=1(收取), longRatio=-1(空头)
期望:MarkClosePnl = 1000×(105100)×1×(1) = 5000 (空头涨价=亏损,经济正确)
```
**它证明两件事**
1. 空头下 `floatRatio` 仍是 **+1**(方向不靠 `floatRatio` 编码)→ 所以 income 只乘 `floatRatio(+1)` 而漏 `longRatio(-1)`
对同一个空头会算出 **+5000**,与权威 5000 相反 → **income 错、unwind 对**
2. 因为两轴独立,**给 income 补 `longRatio` 是纠正、不是双重翻转**"floatRatio 已编码方向"的担忧不成立)。
### 7.3 三条互相独立的证据链(任一都足以定罪)
| # | 证据 | 来源 | 结论 |
|---|------|------|------|
| 1 | 盯市盈亏空头必须翻转符号(空头跌价才盈利)——会计不变式 | 业务数学,独立于代码 | income 漏方向乘子→空头符号必错 |
| 2 | 后端平仓结算 `:1620` 重算并**覆写** `MarkClosePnl=…*floatRatio*longRatio`(入库存后端口径);`SwapIncome:1980-2013` **不重算**、原样存前端值 | `SwapDealService.cs` | 系统自身的权威定义含 longRatioincome 偏离它 |
| 3 | `SwapIncome:1994` 用前端 `SwapRealizedPnl`(由 `MarkClosePnl` 派生)做 `AddClientCash(-SwapRealizedPnl)` | `SwapDealService.cs` | 空头 income 不仅显示错,**实际现金流方向也错**(非纯展示) |
### 7.4 下游是否会"双重翻转 / 补偿性 hack"
- `SwapFlowEventService.cs:589` 对所有流水事件的 `MarkClosePnl/DividendIn/CloseFee/...` **统一取反**(全局视角翻转,firm book→client view),
**不针对 income 或空头**。修复后 income 与 unwind 走同一套取反,对称性不变 → 安全。
- 全仓检索仅此一处 `MarkClosePnl=-MarkClosePnl`,无针对 income/空头的补偿性符号翻转 → 不存在"加了 longRatio 反而翻错"的 hack。
### 7.5 改动安全性论证(如何确保不影响现有正确逻辑)
修复 = 给 income 的 `MarkClosePnl`(含 `FrontendCalcReference.CalcIncome:107`)补 `× longRatio`,使其与权威 unwind 公式口径一致。
1. **静态保证(零回归)**:对所有**多头**(现有 `FC_006~009` 及全部生产多头 income`longRatio=+1`
乘积不变 → 数值**逐位相同**,现有正确行为完全不动。改动是"对多头的恒等变换 + 对空头的纠错",是严格的超集。
2. **回归护栏**
- 跑现有 `FC_001~009` + `SwapFrontendPnlValidateTest` + `SwapIncomeScenarioTest` → 全绿(均为多头,不受影响)。
- 新增 `FC_010`(空头 income`PayDirection=1, PositionType=2`)镜像 `FC_005`,锁定纠正后行为,并断言 `income(空头)==unwind(空头)`parity)。
- jest 红测试 `markClosePnlShortConsistency.test.js` 的"空头"用例由 FAIL 转 PASS,多头用例保持 PASS。
3. **剩余风险(非逻辑正确性,需业务决策)**
- **历史脏数据**:过去"空头+互换"事件已用错符号落库;修复后新事件正确,跨时间对比会出现不连续。需决定:回溯校正(改 `MarkClosePnl`+重算 `SwapRealizedPnl`+对账 `AddClientCash` 历史)还是标注留痕。
- **改动范围**:严格限定在 income 页 `:207``CalcIncome:107``longRatio`,切忌顺手改 `floatRatio` 或其他页面。
4. **前置确认**:建议先让业务/量化签字"income 的 `MarkClosePnl` 应含多空方向(与平仓一致)",再动手——因为结论虽由代码+基线铁证支撑,但涉及客户现金流,需业务背书。