Merge remote-tracking branch 'origin/glms/feature/1.4.2' into glms/feature/1.4.2

This commit is contained in:
tengyufan
2026-06-25 20:00:25 +08:00
10 changed files with 513 additions and 23 deletions
+1
View File
@@ -5,6 +5,7 @@
*.suo
*.user
*.sln.docstates
*.lscache
# Build results
[Dd]ebug/
View File
+8 -5
View File
@@ -7,7 +7,10 @@ namespace YLErp.Helpers
{
/// <summary>
/// DV01计算工具类
/// DV01 = 方向 * (券面总额(万元) / 单位面值) * 中债基点价值
/// 公式: DV01(万元) = 方向 * (持仓面值(万元) / 单位面值) * 中债基点价值
/// 等价业界标准: DV01(元) = 持仓面值(元) / 100 * vobp,本方法输出为该值的 1/10000(即万元)。
/// 注意: PosiQuantity 入参须为"元面值"单位(由 PosiNotionalValue/PosiGrossPrice 反推);
/// 单位面值默认 100,取自 UnderlyingBond.Pricevobp 为"每100元面值1bp的价格变动"。
/// </summary>
public static class Dv01Helper
{
@@ -15,11 +18,11 @@ namespace YLErp.Helpers
/// 计算DV01
/// </summary>
/// <param name="underlyingCode">标的代码</param>
/// <param name="posiQuantity">持仓数量</param>
/// <param name="posiQuantity">持仓数量,须为"元面值"单位(如1000000=100万元面值)</param>
/// <param name="posiDirection">收支方向: 1=收取, 2=支付</param>
/// <param name="positionType">多空方向: 1=多头, 2=空头</param>
/// <param name="vobp">中债基点价值</param>
/// <returns>DV01值</returns>
/// <param name="vobp">中债基点价值(每100元面值,收益率变动1bp的价格变动)</param>
/// <returns>DV01值,单位为"万元"</returns>
public static decimal CalcDv01(string underlyingCode, decimal posiQuantity, int posiDirection, int positionType, decimal vobp)
{
if (vobp == 0 || posiQuantity == 0) return 0;
@@ -32,7 +35,7 @@ namespace YLErp.Helpers
int direction = (posiDirection == (int)SwapDirectionEnum. ? 1 : -1)
* (positionType == (int)PositionTypeFlag.Long ? 1 : -1);
// 券面总额(万元) = 交易数量 / 10000
// 券面总额(万元) = 交易数量(元面值) / 10000
double faceValueWan = Convert.ToDouble(posiQuantity) / 10000d;
// 单位面值, 默认100
@@ -276,6 +276,7 @@ namespace YLErp.Modules.SwapModule
floatEvent.PayDirection = position.PosiDirection;
floatEvent.PosiGrossPrice = position.PosiGrossPrice;
floatEvent.PosiNetPrice = position.PosiNetPrice;
// 注意:TradingAmountNetAvg 字段名为"成交净价(期末语义)",但收益结算/平仓初始化时装入的是期初净价(PosiNetNoFeePrice),前端展示期初净价时取此字段
floatEvent.TradingAmountNetAvg = position.PosiNetNoFeePrice;
floatEvent.TradingAmountNetFeeAvg = position.PosiNetFeePrice;
floatEvent.PositionType = position.PositionType;
@@ -1,6 +1,8 @@
using BaseOUDAL;
using Newtonsoft.Json;
using NPOI.POIFS.Properties;
using System.Linq.Expressions;
using YLErp.DBModels;
using YLErp.DBModels.Consts;
using YLErp.DBModels.Enums;
using YLErp.Helpers;
@@ -448,12 +450,14 @@ namespace YLErp.Modules.SwapModule
PayDate = dividendPayDate,
PositionId = eodPosi.PositionId,
UnderlyingCode = eodPosi.UnderlyingCode,
UnderlyingInstrumentType = eodPosi.UnderlyingInstrumentType,
PayDirection = eodPosi.PosiDirection,
PositionType = eodPosi.PositionType,
PositionQty = eodPosi.PosiQuantity,
Quantity = 0,
ContractSize = eodPosi.ContractSize,
TradingAmountAvg = eodPosi.PosiNetPrice,
TradingAmountNetAvg = eodPosi.PosiNetNoFeePrice,
PosiGrossPrice = eodPosi.PosiGrossPrice,
PosiNetPrice = eodPosi.PosiNetPrice,
MarkClosePnl = eodPosi.PosiDividendSum,
@@ -470,12 +474,13 @@ namespace YLErp.Modules.SwapModule
eodPosi.TdCloseDividend += eodPosi.PosiDividendSum;
eodPosi.TdPosiDividend = 0;
eodPosi.RealizedDividend += eodPosi.PosiDividendSum;
eodPosi.PosiDividendSum = 0;
eodPosi.PosiProfitSum -= eodPosi.TdCloseDividend;
//互换持仓价值要去掉已实现的
eodPosi.SwapPositionValue -= eodPosi.PosiDividendSum;
//已实现盈亏要加上已实现的
eodPosi.RealizedPnl += eodPosi.PosiDividendSum;
eodPosi.PosiDividendSum = 0;
eodPosi.PosiProfitSum -= eodPosi.TdCloseDividend;
}
UnwindData unwindData = new UnwindData();
@@ -1459,6 +1464,12 @@ namespace YLErp.Modules.SwapModule
int shortRatio = eod.PositionType == (int)PositionTypeFlag.Long ? 1 : -1;
int directionRatio = eod.PosiDirection == (int)SwapDirectionEnum. ? 1 : -1;
var price = UnderlyingCodePrice(eod.UnderlyingCode, dealDate, out decimal vobp);
var todayConsumedDividend = CalcConsumedDividend(curretEod, unwindEvents);
var originNotional = (decimal)td.OriginalStockEqvNotional / swapPosition.PosiNetPrice;
BondPaymentService bondPaymentService = new BondPaymentService(UserInfo);
decimal totalPayment = bondPaymentService.CalcPayment(curretEod.UnderlyingCode, td.StartDate.Value, valueDate, (decimal)originNotional, shortRatio, directionRatio);
decimal tax = um.ValueAddedTax ?? 0;
decimal totalInterest = totalPayment / (1 + tax) * (1 - tax);
SetPriceInfoByFlowEvent(eod, curretEod, unwindEvents, swapPosition);
curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp);
curretEod.UnderlyingPrice = price;
@@ -1466,35 +1477,42 @@ namespace YLErp.Modules.SwapModule
curretEod.PosiMtmPnL = (curretEod.UnderlyingPrice - curretEod.PosiGrossPrice) * curretEod.PosiQuantity * curretEod.ContractSize * shortRatio * directionRatio;
curretEod.TdPosiDividend = 0;
// 分红与互换无关,只要持仓>0且起始日早于当前日,正常计算当日分红
// 修改,互换事件会影响待实现的分红的,现在要算上
if (valueDate > td.StartDate.Value && (curretEod.PosiQuantity > 0))
{
decimal tax = um.ValueAddedTax ?? 0;
BondPaymentService bondPaymentService = new BondPaymentService(UserInfo);
decimal payment = bondPaymentService.CalcPayment(curretEod.UnderlyingCode, eod.ValueDate, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio);
curretEod.TdPosiDividend = Math.Round(payment / (1 + tax) * (1 - tax), 2);
}
curretEod.RealizedMtmPnL = eod.RealizedMtmPnL + curretEod.TdCloseMtmPnl;
// 当日浮动端平仓盈亏·分红(仅来自平仓事件 和 互换 中已实现的分红)
curretEod.TdCloseDividend = unwindEvents.Sum(e => e.DividendIn);
curretEod.RealizedFee = eod.RealizedFee + curretEod.TdCloseFee;
curretEod.RealizedPnl = eod.RealizedPnl + curretEod.TdCloseMtmPnl;
curretEod.PosiStatus = curretEod.PosiQuantity == 0 ? 1 : 0;
var closeQty = unwindEvents.Where(x => x.EventType == (int)SwapFlowEventTypeEnum.).ToList().Sum(s => s.Quantity);
// 当日浮动端平仓盈亏·分红(仅来自平仓事件中已实现的分红)
curretEod.TdCloseDividend = unwindEvents.Sum(e => e.DividendIn);
curretEod.RealizedDividend = curretEod.RealizedDividend + curretEod.TdCloseDividend;
// 分红与互换解耦:持仓>0时从起始日重算待实现分红,不再受互换事件影响
// 修改,互换事件会影响待实现的分红的,现在要算上
if (curretEod.PosiQuantity > 0)
{
decimal tax = um.ValueAddedTax ?? 0;
BondPaymentService bondPaymentService = new BondPaymentService(UserInfo);
decimal totalPayment = bondPaymentService.CalcPayment(curretEod.UnderlyingCode, td.StartDate.Value, valueDate, curretEod.PosiQuantity, shortRatio, directionRatio);
curretEod.PosiDividendSum = Math.Round(totalPayment / (1 + tax) * (1 - tax), 2);
//decimal tax = um.ValueAddedTax ?? 0;
//BondPaymentService bondPaymentService = new BondPaymentService(UserInfo);
//decimal totalPayment = bondPaymentService.CalcPayment(curretEod.UnderlyingCode, td.StartDate.Value, valueDate, (decimal)originNotional, shortRatio, directionRatio);
//var totalInterest = totalPayment / (1 + tax) * (1 - tax);
curretEod.PosiDividendSum = Math.Round(totalInterest,2) - curretEod.RealizedDividend;
}
else
{
curretEod.PosiDividendSum = 0;
}
if (curretEod.PosiDividendSum == 0)//这里处理一下已实现盈亏和互换价值,其他在处理分红的时候会处理
{
curretEod.RealizedPnl += curretEod.TdCloseDividend;
curretEod.SwapPositionValue -= curretEod.TdCloseDividend;
}
curretEod.PosiProfitSum = curretEod.PosiMtmPnL + curretEod.PosiDividendSum + curretEod.PosiFeePending;
if (curretEod.PosiStatus == 1)
{
@@ -1513,6 +1531,21 @@ namespace YLErp.Modules.SwapModule
}
return curretEod;
}
private decimal CalcConsumedDividend(eod_swap_position curretEod, List<swap_flow_event> events)
{
decimal consumedDividend = 0;
List<int> swapEventTypes = new List<int>() { (int)SwapEventTypeEnum., (int)SwapEventTypeEnum. };
//这里要剔除掉平仓产生的分红
consumedDividend = events
.Where(x => x.SwapTradeId == curretEod.SwapTradeId
&& swapEventTypes.Contains(x.EventType)
&& x.DataState == (int)SwapFlowDateStateEnum.)
.Sum(s => s.DividendIn);
return consumedDividend;
}
/// <summary>
/// 根据开平仓事件算价格及后付费用
/// </summary>
@@ -1645,6 +1678,7 @@ namespace YLErp.Modules.SwapModule
BondPaymentService bondPaymentService = new BondPaymentService(UserInfo);
decimal payment = bondPaymentService.CalcPayment(curretEod.UnderlyingCode, td.StartDate.Value, settleDate, curretEod.PosiQuantity, shortRatio, directionRatio);
payment = Math.Round(payment / (1 + tax) * (1 - tax), 2);
//var consumedDividend = CalcConsumedDividend(curretEod, unwindEvents); 首日应该没有分红
curretEod.TdPosiDividend = payment;
curretEod.PosiDividendSum = payment;
}
+124 -1
View File
@@ -1,2 +1,125 @@
var main = main || {};
main.formatOptions = { "trading": { "umprice": { "trimTailZeros": true, "precision": 9, "grouping": true, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "umpriceP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "umpricePR": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "tradeSinglePrice": { "trimTailZeros": true, "precision": 9, "grouping": true, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "premiumRateP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "premiumRate": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "tradePrice": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "StockEqvNotional": { "trimTailZeros": true, "precision": 9, "grouping": true, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "notional": { "trimTailZeros": true, "precision": 9, "grouping": true, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "notionalP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "volatility": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "volatilityP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "greek": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 }, "marginRateP": { "trimTailZeros": true, "percent": true, "precision": 9, "grouping": false, "rounded": true, "minDecimals": 2, "maxDecimals": 9 }, "marginRate": { "trimTailZeros": true, "precision": 9, "grouping": false, "rounded": true, "percent": false, "minDecimals": 2, "maxDecimals": 9 } } };
main.formatOptions={
"trading": {
"umprice": {
"precision": 9,
"grouping": true,
"rounded": true,
"percent": false,
"minDecimals": 9,
"maxDecimals": 0
},
"umpriceP": {
"percent": true,
"precision": 2,
"grouping": false,
"rounded": true,
"minDecimals": 2,
"maxDecimals": 0
},
"umpricePR": {
"precision": 4,
"grouping": false,
"rounded": true,
"percent": false,
"minDecimals": 4,
"maxDecimals": 2
},
"tradeSinglePrice": {
"precision": 2,
"grouping": true,
"rounded": true,
"percent": false,
"minDecimals": 2,
"maxDecimals": 0
},
"premiumRateP": {
"percent": true,
"precision": 2,
"grouping": false,
"rounded": true,
"minDecimals": 2,
"maxDecimals": 0
},
"premiumRate": {
"precision": 4,
"grouping": false,
"rounded": true,
"percent": false,
"minDecimals": 4,
"maxDecimals": 2
},
"tradePrice": {
"precision": 2,
"grouping": false,
"rounded": true,
"percent": false,
"minDecimals": 2,
"maxDecimals": 0
},
"StockEqvNotional": {
"precision": 2,
"grouping": true,
"rounded": true,
"percent": false,
"minDecimals": 2,
"maxDecimals": 0
},
"notional": {
"precision": 2,
"grouping": true,
"rounded": true,
"percent": false,
"minDecimals": 2,
"maxDecimals": 0
},
"notionalP": {
"percent": true,
"precision": 2,
"grouping": false,
"rounded": true,
"minDecimals": 2,
"maxDecimals": 0
},
"volatility": {
"precision": 4,
"grouping": false,
"rounded": true,
"percent": false,
"minDecimals": 4,
"maxDecimals": 2
},
"volatilityP": {
"percent": true,
"precision": 2,
"grouping": false,
"rounded": true,
"minDecimals": 2,
"maxDecimals": 0
},
"greek": {
"precision": 2,
"grouping": false,
"rounded": true,
"percent": false,
"minDecimals": 2,
"maxDecimals": 0
},
"marginRateP": {
"percent": true,
"precision": 2,
"grouping": false,
"rounded": true,
"minDecimals": 2,
"maxDecimals": 0
},
"marginRate": {
"precision": 4,
"grouping": false,
"rounded": true,
"percent": false,
"minDecimals": 4,
"maxDecimals": 2
}
}
};
+6 -2
View File
@@ -152,9 +152,13 @@
{{floatPosition.UnderlyingCode}}
</td>
<td>{{priceFormat(floatPosition.PosiGrossPrice)}}</td>
<td v-if="deal.StructureType!='普通收益互换'">{{priceFormat(floatPosition.TradingAmountNetAvg)}}</td>
<!-- 期初标的交割净价: TradingAmountNetAvg 字段名为"成交净价(期末语义)",但此处后端 InitIncome 实际装入的是期初净价(position.PosiNetNoFeePrice),值是期初值 -->
<td v-if="deal.StructureType!='普通收益互换'">{{priceFormat(floatPosition.TradingAmountNetAvg > 0 ? floatPosition.TradingAmountNetAvg : floatPosition.PosiNetPrice)}}</td>
<td>
{{priceFormat(floatPosition.TradingAmountAvg)}}
<vue-number-input v-model="floatPosition.TradingAmountAvg" v-bind:format="inputFormatEqvNotional" v-on:input="changeUnderlyingPrice" style="width:107px;"></vue-number-input>
<a href="javascript:void(0)" v-on:click="refreshUnderlyingPrice()">
<span title="使用系统标的价格" class="glyphicon glyphicon-refresh"></span>
</a>
</td>
<td>{{floatPosition.Quantity}}</td>
<td>
@@ -43,7 +43,8 @@ const vue = new Vue({
this.floatPosition = positions[0];
this.initPosiNetPrice = this.floatPosition.PosiNetPrice;
// 互换标的价格固定为期初净价,与平仓不同不需要用户填写
this.floatPosition.TradingAmountAvg = this.initPosiNetPrice;
// 期初净价入库为相对价(如1.02),需转换为界面百分比形态(102),与平仓页保持一致
this.floatPosition.TradingAmountAvg = this.initPosiNetPrice * this.multiplier;
this.interestList = model.FlowEvents.filter((item) => {
return item.InterestMode == 1 || item.InterestMode == 2 || item.InterestMode == 7 || item.InterestMode == 8 || item.InterestMode == 9;
});
@@ -96,11 +97,23 @@ const vue = new Vue({
}
if (!isUseApproval) {
this.getInterestList();
//this.refreshUnderlyingPrice();
} else {
this.dataFormat();
}
},
changeUnderlyingPrice() {//修改标的价格
this.calcFloatClosePnl();
},
refreshUnderlyingPrice() {//刷新标的价格
var thisObj = this;
main.post("/underlying_manager/GetUnderlyingPriceByCode",
{ code: thisObj.floatPosition.UnderlyingCode, valuedate: thisObj.deal.ValueDate })
.done(function (res) {
res.obj = res.obj * thisObj.multiplier;
thisObj.floatPosition.TradingAmountAvg = otcformat.trading.tradeSinglePrice(res.obj);
thisObj.calcFloatClosePnl();
});
},
changeTradingFee() {//修改交易费用
this.calcFloatClosePnl();
},
@@ -280,10 +280,13 @@ const vue = new Vue({
let floatRatio = this.floatPosition.PayDirection == 1 ? 1 : -1;
var postData = { startDate: thisObj.TradeStartDate, endDate: thisObj.deal.UnwindDate, underlyingCode: thisObj.floatPosition.UnderlyingCode, tradeId: thisObj.deal.SwapTradeId, unwindDate: thisObj.deal.UnwindDate }
main.post("/BondPayment/GetBondPayMentInterest", postData, { async: false }).done(function (resp) {
let totalDividend = parseFloat(thisObj.deal.CloseQty) * resp.obj.totalInterest * ratio * floatRatio;
let consumedDividend = parseFloat(resp.obj.consumedDividend ?? 0);
let consumedDividendInterest = -1 * consumedDividend / parseFloat(thisObj.deal.NotionalQty)//调整一下方向
// 这里计算已经分红的利息,从totalInterest里扣除
let totalDividend = parseFloat(thisObj.deal.CloseQty) * (resp.obj.totalInterest - consumedDividendInterest) * ratio * floatRatio;
// 互换是全量消费,consumedDividend>0 表示分红已被当天互换消费,归0
thisObj.floatPosition.DividendIn = Math.abs(consumedDividend) > 0 ? parseFloat((totalDividend - consumedDividend).toFixed(2)) : parseFloat(totalDividend.toFixed(2));
// 现在可能做了纯分红的互换结算,所以不能直接归0
thisObj.floatPosition.DividendIn = parseFloat(totalDividend.toFixed(2));
var posiQty = parseFloat(thisObj.floatPosition.Quantity) - parseFloat(thisObj.deal.CloseQty);
thisObj.floatPosition.DividendPending = parseFloat((posiQty * resp.obj.totalInterest * ratio * floatRatio).toFixed(2));
thisObj.calcFloatClosePnl();
@@ -0,0 +1,308 @@
# 互换交易价格字段存储与显示规范
> 本文档总结互换交易(收益互换)中"标的价格"类字段在**录入、入库、展示**三个环节的处理规则,以及债券类与普通(非债券)类的差异。
> 该规则是 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` 拷贝过来用于展示。