汇总互换价格显示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:字段命名与数据流向速查表
16 KiB
互换交易价格字段存储与显示规范
本文档总结互换交易(收益互换)中"标的价格"类字段在录入、入库、展示三个环节的处理规则,以及债券类与普通(非债券)类的差异。 该规则是 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 里执行:
<!-- 普通债券类收益互换: 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):
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 判断是否债券:
@{
// 关键: 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:
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) |
教训
- 生成
swap_flow_event时必须赋全字段,尤其是UnderlyingInstrumentType——它是展示层判断债券/非债券(决定 ×100)的关键,遗漏会导致显示数量级错误。 - 手动互换与自动互换共用同一套展示代码,入库基准(相对价)必须保持一致。
- 字段命名误导:
TradingAmountNetAvg名字像"期末",但收益结算初始化时装的是期初净价,改代码时务必核对实际数据流向。 - 可空字段要兜底:
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 个价格字段(确认入库基准)
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。
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 查不到,需去掉该条件排查。
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(基点价值)。
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:
-- 备份
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 拷贝过来用于展示。