Files
zszq-trs/项目文档/互换交易价格字段存储与显示规范.md
hjhan 697c7babf2 新增文档:互换交易价格字段存储与显示规范
汇总互换价格显示Bug排查结论,供后续维护参考:
- 核心规则:债券类入库相对价(÷100)、展示×100;非债券类原样
- ÷100机制:由FastVue.parseNumber实现(percent:true时number/100)
- 关键字段映射:swap_position/swap_flow_event/eod_swap_position
  (标注TradingAmountNetAvg名像期末实装期初、PosiGrossPrice在
  event表为NotMapped等易混点)
- 互换事件生成路径:手动/自动互换-分红型/利息端
- 已修复Bug与教训(4个)
- 附录A:排查工具箱SQL(5个,期初持仓/事件入库值/分红数据源/
  中债估值/脏数据修复)
- 附录B:字段命名与数据流向速查表
2026-06-25 19:15:54 +08:00

309 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 互换交易价格字段存储与显示规范
> 本文档总结互换交易(收益互换)中"标的价格"类字段在**录入、入库、展示**三个环节的处理规则,以及债券类与普通(非债券)类的差异。
> 该规则是 2026-06 一系列价格显示 Bug 排查后总结的结论,供后续开发维护参考,避免再次出现"多除/少乘 100"类问题。
---
## 一、核心规则速查
| 标的类型 | 录入存库 | 入库基准 | 展示规则 |
|---------|---------|---------|---------|
| **普通债券类收益互换**(债券标的,如 180205.IB) | 用户输入值 ÷ 100 存库(**相对价**) | 相对价,如 `1.02`(对应输入 102) | 相对价 × 100 展示,如 `102.00000%` |
| **普通收益互换**(非债券标的,如 ETF、商品) | 用户输入值原样存库 | 绝对价,如 `100`(对应输入 100) | 原样展示,如 `100.00000` |
**一句话**:债券类的价格,库里存"相对价(÷100)",界面显示时"×100 还原"。普通非债券类输入什么就存什么、显示什么。
---
## 二、为什么债券类要 ÷100 / ×100
债券价格习惯用"面值的百分比"表达(全价 102 元 = 面值 100 的 102%)。
为了与其他利率/收益率类字段在库里保持"小数形式(1.02)"统一,录入时由 `vue-number-input` 组件自动 ÷100 转成相对价存库;展示时再 ×100 还原成用户习惯的百分比形式。
---
## 三、关键字段与所在表
互换涉及的价格字段命名容易混淆(有的字段名是"期末语义"但实际装的是"期初值"),现梳理如下:
### swap_position 表(期初持仓,`IsInitial=1`
| 字段 | 含义 | 备注 |
|------|------|------|
| `PosiGrossPrice` | 期初全价(不含费) | 相对价基准(债券为 1.02 形式) |
| `PosiNetPrice` | 期初净价(含费) | 相对价基准 |
| `PosiNetNoFeePrice` | 成交净价(不含费),**可空** | 收益结算页"期初净价"列取此字段;为空时需回退到 `PosiNetPrice` |
| `PosiNetFeePrice` | 成交净价(含费),**可空** | |
### swap_flow_event 表(互换/平仓/自动互换事件)
| 字段 | 含义 | 是否入库 | 备注 |
|------|------|---------|------|
| `TradingAmountAvg` | 成交均价(期末全价) | ✅ 入库 | 债券类存相对价 |
| `TradingAmountNetAvg` | 成交净价(不含费) | ✅ 入库(可空) | 字段名像"期末",但收益结算初始化时装的是**期初净价** |
| `TradingAmountNetFeeAvg` | 成交净价(含费) | ✅ 入库 | |
| `TradingAmount` | 成交金额 | ✅ 入库 | = `TradingAmountAvg × Quantity × ContractSize`(后端计算) |
| `PosiGrossPrice` | 期初全价 | ❌ `[NotMapped]` 不入库 | 运行时从 `swap_position` 拷贝 |
| `PosiNetPrice` | 期初净价 | ❌ `[NotMapped]` 不入库 | 运行时从 `swap_position` 拷贝 |
| `UnderlyingInstrumentType` | 标的类型 | ✅ 入库 | **展示时判断是否债券(决定 ×100)的关键字段,生成事件时必须赋值** |
> ⚠️ `PosiGrossPrice` / `PosiNetPrice` 在 `swap_flow_event` 里是 `[NotMapped]`**数据库查不到这两列**,只在运行时从 `swap_position` 拷贝。所以"期初价"展示的实际数据源是 `swap_position`,不是 `swap_flow_event`。
### eod_swap_position 表(日终持仓)
`PosiDividendSum`(浮动端未实现分红收益)≠ 0 时,会触发分红型自动互换(`DealDividends`)。
---
## 四、各环节的代码实现位置
### 1. 录入(TradeEdit.cshtml + swapTradeEdit.js
债券类与普通类用**不同的输入格式器**(只是配置开关),真正的 ÷100 转换在 `FastVue.parseNumber` 里执行:
```html
<!-- 普通债券类收益互换: percent:true, 输入102 → v-model=1.02 (÷100) -->
<vue-number-input v-model="item.PosiGrossPrice" v-bind:format="inputFormatMarginRateNoPercent">
<!-- 普通收益互换(非债券): percent:false, 输入100 → v-model=100 (原样) -->
<vue-number-input v-model="item.PosiGrossPrice" v-bind:format="inputFormatTradeSinglePrice">
```
格式器定义(swapTradeEdit.js,仅配置开关):
- `inputFormatMarginRateNoPercent``percent: true`(注意:名字里的 "NoPercent" 指"显示时不带%号",而非"不做换算",容易误解)
- `inputFormatTradeSinglePrice``percent: false`
**÷100 的真正实现**`statics/bundles/bundle.js``FastVue.parseNumber`):
```js
FastVue.parseNumber = function (val, percent) {
// ...
let isPercent = val.endsWith('%') || percent == true; // format.percent=true 时
let number = new Number(val.replace(/%+$/, ''));
return isPercent ? number / 100 : number; // ← ÷100 在这里执行
};
```
即:组件本身基于第三方库(打包进 bundle.js),但 `percent:true``number / 100` 的换算是项目自定义的 `FastVue.parseNumber` 实现的,不是第三方库原生行为。
### 2. 展示(TradeView.cshtml
互换 Tab(浮动收益端)对期初/期末价的处理,**依赖 `UnderlyingInstrumentType` 判断是否债券**
```cshtml
@{
// 关键: multiplier 由标的类型决定, 债券=100, 非债券=1
var multiplier = ConsGlobal.InstrumentType.IsBond(closeFloat.UnderlyingInstrumentType) ? 100 : 1;
}
@if (isBond) {
<td>@((closeFloat.PosiGrossPrice * multiplier).OtcFormat(OtcFormatFlag.umprice))</td>
<td>@((closeFloat.TradingAmountAvg * multiplier).OtcFormat(OtcFormatFlag.umprice))</td>
} else {
<td>@(closeFloat.PosiGrossPrice.OtcFormat(OtcFormatFlag.umprice))</td>
<td>@(closeFloat.TradingAmountAvg.OtcFormat(OtcFormatFlag.umprice))</td>
}
```
> 🔑 **`UnderlyingInstrumentType` 是展示层判断的关键**。所有生成 `swap_flow_event` 的地方都必须赋值这个字段,否则 `IsBond` 判断失败,债券标的会走非债券分支(不 ×100),导致显示成相对价(1.00 而非 100.00)。
### 3. 收益结算页(SwapIncome.cshtml + incomeSwapTrade.js
前端 `priceFormat()` 同样 `× multiplier`
```js
priceFormat(price) {
price = price * this.multiplier; // 债券类 ×100
return otcformat.trading.umprice(price);
}
```
---
## 五、互换事件的生成路径与价格赋值
互换 Tab 会按时间序列展示所有互换事件(EventType=互换/自动互换),手动与自动用**同一套渲染代码**,所以入库基准必须一致。
### 手动互换(EventType=3
- 入口:`SwapDealService.SwapUnwind` / `InitIncome`
- 价格赋值:`floatEvent.TradingAmountAvg = unwindPrice`(前端用户输入,已 ÷100
- **已赋值 `UnderlyingInstrumentType`**(来自 position
### 自动互换-分红型(EventType=4)⚠️ 重点
- 入口:`SwapEodPositionService.DealDividends``PosiDividendSum != 0` 触发)
- 价格赋值:`TradingAmountAvg = eodPosi.PosiNetPrice`(相对价,来自 eod 持仓)
- **必须赋值 `UnderlyingInstrumentType``TradingAmountNetAvg`**(曾经遗漏导致 Bug
### 自动互换-利息端(EventType=4
- 入口:`DealAutoInterests``SaveAutoEodWithCloseInterestPosition`
- `UnderlyingCode` 为空(利息腿),不出现在互换 Tab 浮动端
---
## 六、已修复的 Bug 与教训
| Bug | 根因 | 修复提交 |
|-----|------|---------|
| 收益结算期末价缩放/显示错误 | `incomeSwapTrade.js` initDeal 合并冲突丢 ×multiplier | 已修 |
| 收益结算期初净价显示 0 | `PosiNetNoFeePrice` 可空,为 0 时未回退 `PosiNetPrice` | 已修 |
| 收益结算期末价不可编辑/无刷新图标 | `ab09cbc9` 误删输入框与方法 | 已修 |
| **分红型自动互换显示未 ×100** | `DealDividends` 漏赋 `UnderlyingInstrumentType` | 已修(d9c5ce4c|
| **分红型自动互换期初净价显示 0** | `DealDividends` 漏赋 `TradingAmountNetAvg` | 已修(d9c5ce4c|
### 教训
1. **生成 `swap_flow_event` 时必须赋全字段**,尤其是 `UnderlyingInstrumentType`——它是展示层判断债券/非债券(决定 ×100)的关键,遗漏会导致显示数量级错误。
2. **手动互换与自动互换共用同一套展示代码**,入库基准(相对价)必须保持一致。
3. **字段命名误导**`TradingAmountNetAvg` 名字像"期末",但收益结算初始化时装的是期初净价,改代码时务必核对实际数据流向。
4. **可空字段要兜底**`PosiNetNoFeePrice``TradingAmountNetAvg` 可空,展示时为 0/空要有回退值。
---
## 七、历史脏数据说明
排查过程中发现,测试环境里同一笔手动互换的 `swap_flow_event` 可能存在多条记录,`TradingAmountAvg` 取值不一致(有的相对价如 1.0011,有的被 ÷100 如 0.010011),成交金额相应相差 100 倍。这是早期代码版本更迭残留的脏数据。
判断脏数据的依据:成交金额 `TradingAmount = TradingAmountAvg × Quantity × ContractSize`(后端 `SwapDealService:1103` 计算)。若同一交易的两条互换记录成交金额相差正好 100 倍,则其中一条的 `TradingAmountAvg` 被多 ÷100。
这类历史数据问题用 SQL 单独修复,**不应在代码里迁就脏数据而保留错误的逻辑**。排查与修复 SQL 见文末附录。
---
## 附录 A:排查工具箱 SQL
> 以下 SQL 是 2026-06 价格显示 Bug 排查过程中验证有效的查询,数据库为 MySQL,库名 `glms_yltrs_ylcms`ylcms 连接)。后续遇到类似"显示数量级不对"问题可直接复用。
### A.1 查互换交易期初持仓的 4 个价格字段(确认入库基准)
```sql
SELECT
t.TradeNumber AS trade_no,
t.StructureType AS 结构类型,
sp.UnderlyingCode AS 标的代码,
sp.PosiGrossPrice AS 期初全价,
sp.PosiNetPrice AS 期初净价,
sp.PosiNetNoFeePrice AS 期初净价不含费,
sp.PosiNetFeePrice AS 期初净价含费
FROM trade t
JOIN swap_position sp ON sp.SwapTradeId = t.id
AND sp.IsInitial = 1
AND sp.UnderlyingCode IS NOT NULL -- 过滤利息腿/预付金腿
WHERE t.TradeType = '收益互换'
AND t.StructureType IN ('普通债券类收益互换', '普通收益互换')
AND t.ValidState = 'valid'
ORDER BY t.id DESC
LIMIT 20;
```
### A.2 查互换/自动互换事件入库值(区分手动/自动、用成交金额反推正确基准)
⚠️ `swap_flow_event` 表的 `PosiGrossPrice`/`PosiNetPrice``[NotMapped]`,**数据库查不到这两列**;只能查真正入库的 `TradingAmountAvg``TradingAmountNetAvg``TradingAmount`
```sql
SELECT
t.TradeNumber AS trade_no,
fe.id AS 事件ID,
fe.EventType AS 事件类型,
CASE fe.EventType WHEN 3 THEN '手动互换' WHEN 4 THEN '自动互换' END AS 类型,
fe.EventReason AS 事件原因,
fe.EventDate AS 业务日期,
fe.UnderlyingCode AS 标的,
fe.PayDirection AS 收支方向,
fe.PositionType AS 多空方向,
fe.UnderlyingInstrumentType AS 标的类型_判断债券用,
fe.TradingAmountAvg AS 期末全价_入库,
fe.TradingAmountNetAvg AS 期末净价_入库,
fe.TradingAmount AS 成交金额,
fe.DividendIn AS 分红金额
FROM trade t
JOIN swap_flow_event fe ON fe.SwapTradeId = t.id
WHERE t.id IN (/*填trade id*/)
AND t.ValidState = 'valid'
AND fe.EventType IN (3, 4)
AND fe.UnderlyingCode IS NOT NULL -- 只要浮动端事件,排除利息腿
ORDER BY t.id, fe.EventDate DESC, fe.id DESC
LIMIT 30;
```
**判断要点**
- `UnderlyingInstrumentType` 为空 → 该事件的展示会走"非债券分支(不×100)",债券标的显示会错(这就是自动互换的 Bug 根因)。
- 同一交易同一腿出现 `TradingAmountAvg` 相差 100 倍的两条记录 → 其中一条是被多 ÷100 的脏数据。
### A.3 查分红型自动互换的数据源(eod_swap_position,确认 PosiDividendSum 触发条件)
`PosiDividendSum != 0` 时触发 `DealDividends` 生成自动互换事件。注意测试数据可能为 0 查不到,需去掉该条件排查。
```sql
SELECT
esp.SwapTradeId AS trade_id,
esp.ValueDate AS 持仓日期,
esp.UnderlyingCode AS 标的代码,
esp.UnderlyingInstrumentType AS 标的类型,
esp.PosiDividendSum AS 浮动端未实现分红_触发条件,
esp.TdPosiDividend AS 当日浮动端分红,
esp.PosiNetPrice AS 持仓净价_自动互换TradingAmountAvg来源,
esp.PosiGrossPrice AS 持仓全价,
esp.PosiNetNoFeePrice AS 持仓净价不含费_期初净价来源
FROM eod_swap_position esp
WHERE esp.SwapTradeId IN (/*填trade id*/)
ORDER BY esp.ValueDate DESC
LIMIT 20;
```
### A.4 查中债估值数据源(确认录入时默认读取的价格)
簿记交易时标的默认价格来自 `china_bond_valuation`(中债估值)表。⚠️ **该表的字段命名与业务常用名差异很大**,特别注意:债券代码列是 `bond_id`(不是 UnderlyingCode),价格列是 `dirty_price_close`(日终估值全价)、`net_price`(估价净值)、`yield`(收益率%)、`vobp`(基点价值)。
```sql
SELECT
cbv.bond_id AS 债券代码,
cbv.valuation_date AS 估值日期,
cbv.dirty_price_close AS 日终估值全价, -- 录入默认读取的"全价"来源
cbv.net_price AS 估价净值,
cbv.yield AS 估价收益率_pct,
cbv.vobp AS 估价基点价值, -- DV01 计算用此字段
cbv.acc_rint_close AS 日终应计利息,
cbv.credibility AS 可信度 -- GetBondPrice 按可信度降序取最新
FROM china_bond_valuation cbv
WHERE cbv.bond_id = '180205.IB'
ORDER BY cbv.credibility DESC, cbv.valuation_date DESC
LIMIT 10;
```
> 后端 `EodPriceQueryService.GetBondPrice` 取数逻辑:按 `bond_id` 匹配,`valuation_date <= 指定日`,按 `credibility` 降序、`valuation_date` 降序取第一条。返回的 `ClosePrice = dirty_price_close × bondPriceMultiple`(默认乘数通常为 1)。测试环境可能被维护成离谱值(如全价 1999)。
### A.5 历史脏数据修复(手动互换被多 ÷100 的记录)
⚠️ **执行前务必先用 A.2 核对,并备份**。以下仅作为模板,需根据实际范围调整 WHERE:
```sql
-- 备份
CREATE TABLE swap_flow_event_bak_yyyymmdd AS
SELECT * FROM swap_flow_event WHERE /*同下方WHERE条件*/;
-- 修复: 被多÷100的TradingAmountAvg ×100还原, 成交金额同步×100
UPDATE swap_flow_event fe
JOIN trade t ON t.id = fe.SwapTradeId
SET fe.TradingAmountAvg = fe.TradingAmountAvg * 100,
fe.TradingAmount = fe.TradingAmount * 100
WHERE t.StructureType = '普通债券类收益互换'
AND fe.EventType = 3 -- 手动互换
AND fe.UnderlyingCode IS NOT NULL
AND fe.TradingAmountAvg > 0
AND fe.TradingAmountAvg < 0.5; -- 相对价合理范围判断(债券相对价通常0.5~2)
```
---
## 附录 B:字段命名与数据流向速查
容易混淆的字段对应关系(排查时务必核对实际数据流向,不要被字段名误导):
| 界面列(表头) | 取值字段 | 字段所在表 | 实际数据来源 | 备注 |
|---|---|---|---|---|
| 期初标的交割全价% | `PosiGrossPrice` | swap_position(入库)/ swap_flow_event[NotMapped]运行时拷贝) | `swap_position.PosiGrossPrice` | 相对价,展示×100 |
| 期初标的交割净价% | `TradingAmountNetAvg` | swap_flow_event(入库) | `swap_position.PosiNetNoFeePrice`(收益结算初始化时) | **字段名像期末,实装期初值**;可空需兜底 |
| 期末标的交割全价% | `TradingAmountAvg` | swap_flow_event(入库) | 手动互换=用户输入;自动互换=`eod.PosiNetPrice` | 相对价,展示×100 |
| 标的类型判断 | `UnderlyingInstrumentType` | swap_flow_event(入库) | 生成事件时赋值 | **展示层判断债券(×100)的关键,必须赋值** |
| 成交金额 | `TradingAmount` | swap_flow_event(入库) | `TradingAmountAvg × Quantity × ContractSize` | 反推正确基准的依据 |
**关键提醒**`swap_flow_event.PosiGrossPrice``PosiNetPrice``[NotMapped]`,数据库**没有这两列**,查库会报 `Unknown column`。它们只在运行时从 `swap_position` 拷贝过来用于展示。