feat(web): EQD-6953 平仓页接入期末结算收益率互算——SwapUnwind浮动端新增列(仅普通债券类收益互换,控件/事件/源AUTO REV标识与录入页同款vue-swap-price-input);DP回车→算ExitYtm(4位)、YD回车→反算交割全价并重算平仓盈亏、手填未回车=REV可覆盖;估值日=平仓日,改日期清陈旧值;防御:applyCalcFailure非法driver只清标识防误清交割全价(补回归测试);closePercentInterestRefresh沙箱补UnwindBondCalc mock对齐页面脚本序;209/209通过

This commit is contained in:
hjhan
2026-08-20 17:38:43 +08:00
parent 45d4bc1d26
commit 4bd528f966
5 changed files with 156 additions and 4 deletions
+10 -1
View File
@@ -26,6 +26,7 @@
<script src="~/front/swappriceprecision?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/swapPricePrecisionHelper.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/swapCalc.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/unwindBondCalc.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/unwindSwapTrade.js?v=@HtmlUtil.JsVersion"></script>
}
<div class="pb-3" id="vueDiv">
@@ -197,6 +198,8 @@
<td v-else>期初标的价格</td>
<td v-if="deal.StructureType!='普通收益互换'">期末标的交割全价%</td>
<td v-else>期末标的价格</td>
@* EQD-6953 期末标的结算收益率:仅普通债券类收益互换显示,期末交割全价回车反算,可手填覆盖 *@
<td v-if="deal.StructureType=='普通债券类收益互换'">期末标的结算收益率%</td>
<td>数量</td>
<td>交易费用(平仓)</td>
<td>交易费用(待结算)</td>
@@ -223,11 +226,17 @@
</td>
<td>{{priceFormat(floatPosition.PosiGrossPrice)}}</td>
<td>
<vue-swap-price-input class="swap-delivery-price-input-unwind" v-model="floatPosition.TradingAmountAvg" v-bind:format="getDeliveryPriceInputFormat()" v-on:input="changeUnderlyingPrice"></vue-swap-price-input>
<vue-swap-price-input class="swap-delivery-price-input-unwind" v-model="floatPosition.TradingAmountAvg" v-bind:format="getDeliveryPriceInputFormat()" v-on:input="onEndDeliveryPriceInput" v-on:enter="onEndBondPriceEnter('DP')" v-on:keydown="onEndBondPriceKeydown('DP',$event)"></vue-swap-price-input>
<span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="isBondUnwindLeg && floatPosition.bondDriverType==='DP'">源</span><span style="color:#1565c0;font-size:11px;margin-left:4px;" v-else-if="isBondUnwindLeg && floatPosition.bondAuto && floatPosition.bondAuto.DP">AUTO</span><span style="color:#ef6c00;font-size:11px;margin-left:4px;" v-if="isBondUnwindLeg && floatPosition.bondRev && floatPosition.bondRev.DP" title="人工输入:未回车触发计算器">REV</span>
<a href="javascript:void(0)" v-on:click="refreshUnderlyingPrice()">
<span title="使用系统标的价格" class="glyphicon glyphicon-refresh"></span>
</a>
</td>
@* EQD-6953 期末标的结算收益率:回车以它为源反算交割全价(联动盈亏);手填未回车=REV人工输入 *@
<td v-if="deal.StructureType=='普通债券类收益互换'">
<vue-swap-price-input v-model="floatPosition.ExitYtm" v-bind:format="getYieldInputFormat()" v-on:input="onEndBondPriceEdit('YD')" v-on:enter="onEndBondPriceEnter('YD')" v-on:keydown="onEndBondPriceKeydown('YD',$event)"></vue-swap-price-input>
<span style="color:#2e7d32;font-size:11px;margin-left:4px;" v-if="isBondUnwindLeg && floatPosition.bondDriverType==='YD'">源</span><span style="color:#1565c0;font-size:11px;margin-left:4px;" v-else-if="isBondUnwindLeg && floatPosition.bondAuto && floatPosition.bondAuto.YD">AUTO</span><span style="color:#ef6c00;font-size:11px;margin-left:4px;" v-if="isBondUnwindLeg && floatPosition.bondRev && floatPosition.bondRev.YD" title="人工输入:未回车触发计算器">REV</span>
</td>
<td>{{formatQuantity(deal.CloseQty)}}</td>
<td>
<vue-number-input v-model="floatPosition.TradingFee" v-bind:format="inputFormatCloseAmount" v-on:input="changeTradingFee"></vue-number-input>
@@ -148,6 +148,13 @@ function loadVueApp(model, mockPost) {
return Number(Number(positionQty) * (Number(closePercent) / ori).toFixed(6));
}
},
// EQD-6953:平仓页现于 unwindSwapTrade.js 之前加载 unwindBondCalc.js(见 SwapUnwind.cshtml),
// dataFormat 提交取整会引用其守卫;此处给同款行为的最小 mock(真实实现由 unwindBondCalc.test.js 覆盖)
UnwindBondCalc: {
hasValue(v) { return v !== null && v !== undefined && v !== '' && !isNaN(v); },
roundExitYtm(v) { return v; },
applyManualEdit(s) { return s; }
},
_: {
round(value, precision) {
return Number(Number(value || 0).toFixed(precision || 0));
+9
View File
@@ -95,6 +95,15 @@ describe('applyCalcFailure:保留源字段、只清对方字段、标识全清
expect(state.ExitYtm).toBe(6.3);
expect(state.TradingAmountAvg).toBeNull();
});
test('driver 非法(null):仅清标识、不动任何数值(setValueDate 清陈旧标识场景的防误清守卫)', () => {
const state = { TradingAmountAvg: 100.5, ExitYtm: 6.3, bondDriverType: 'DP', bondAuto: { DP: false, YD: true } };
UnwindBondCalc.applyCalcFailure(state, null);
expect(state.TradingAmountAvg).toBe(100.5); // 绝不能被清
expect(state.ExitYtm).toBe(6.3);
expect(state.bondDriverType).toBeNull();
expect(state.bondAuto).toEqual({ DP: false, YD: false });
});
});
describe('标识状态机(交互约定2/3,对齐录入页)', () => {
@@ -83,10 +83,14 @@
/**
* 计算失败落地(交互约定2):保留源字段值(用户可就地改),清对方字段、清全部标识。
* 与录入页差异:只清"对方"一个字段(录入页清另外两个)。
* driver 非法(null/未知)时仅清标识、不动任何数值——防调用方误清源字段
* (如 setValueDate 只想清陈旧标识时误传 null,会把 TradingAmountAvg 清掉)。
*/
function applyCalcFailure(state, driver) {
var other = driver === 'DP' ? 'YD' : 'DP';
state[FIELDS[other]] = null;
if (driver === 'DP' || driver === 'YD') {
var other = driver === 'DP' ? 'YD' : 'DP';
state[FIELDS[other]] = null;
}
state.bondDriverType = null;
state.bondAuto = { DP: false, YD: false };
state.bondRev = { DP: false, YD: false };
@@ -11,6 +11,9 @@ const formatSwapQuantity = value => swapPricePrecision.normalizeCommon('quantity
const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' });
const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent: true });
const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, append: '', negative: true });
// EQD-6953 期末标的结算收益率(ExitYtm):展示态百分数(如 6.3721)trimTailZeros:false 不去零;
// 精度由 getInputFormat('yield') 规则给 4 位——不得低于结算确认书导出的固定 4 位(0.0000)。
const inputFormatUnwindExitYtm = Object.freeze({ append: '', negative: true, trimTailZeros: false });
const consPosiFeeType = Object.freeze({ Percent: 0, Unit: 1 });
const swapPosiFeeCalc = {
normalizeFeeType(feeType) {
@@ -61,6 +64,8 @@ const vue = new Vue({
marginList: [],
initPosiNetPrice: 0,
multiplier: 1,
// EQD-6953 簿记模板=普通债券类收益互换 时启用 期末交割全价↔结算收益率(ExitYtm) 互算
isBondTRS: false,
// 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。
// oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。
oriClosePercent: 1,
@@ -73,10 +78,15 @@ const vue = new Vue({
},
minStartDate() {
return this.deal.StartDate;
},
// EQD-6953:簿记模板为普通债券类收益互换 且 浮动腿标的为债券 时,才展示 源/AUTO/REV 标识并允许互算
isBondUnwindLeg() {
return this.isBondTRS && !!this.floatPosition && this.IsBond(this.floatPosition.UnderlyingInstrumentType);
}
},
created() {
this.multiplier = this.deal.StructureType == '普通债券类收益互换' ? 100 : 1;
this.isBondTRS = this.deal.StructureType == '普通债券类收益互换';
this.multiplier = this.isBondTRS ? 100 : 1;
this.initDeal();
this.setValueDate(this.deal.ValueDate);
},
@@ -151,6 +161,10 @@ const vue = new Vue({
this.floatPosition.TradingAmountAvg,
this.floatPosition.UnderlyingInstrumentType,
'grossPrice');
// EQD-6953 期末结算收益率:提交前统一 4 位四舍五入(幂等;空/非数保持原样不覆盖)
if (UnwindBondCalc.hasValue(this.floatPosition.ExitYtm)) {
this.floatPosition.ExitYtm = UnwindBondCalc.roundExitYtm(this.floatPosition.ExitYtm);
}
this.floatPosition.TradingFee = formatSwapAmount(this.floatPosition.TradingFee);
this.floatPosition.TradingFeePending = formatSwapAmount(this.floatPosition.TradingFeePending);
this.floatPosition.DividendIn = formatSwapAmount(this.floatPosition.DividendIn);
@@ -174,6 +188,14 @@ const vue = new Vue({
this.deal.UnwindDate = e;
this.floatPosition.UnwindDate = e;
}
// EQD-6953 估值日(平仓日)变了,已算出的期末结算收益率随之失效:清值+清标识,待用户重新回车计算
// applyManualEdit(null) 只清 源/AUTO/REV 不动任何数值;勿用 applyCalcFailure(null)——
// 它对 null driver 会误清 TradingAmountAvg
if (this.isBondUnwindLeg && this.floatPosition.ExitYtm != null) {
this.floatPosition.ExitYtm = null;
UnwindBondCalc.applyManualEdit(this.floatPosition, null);
this.syncUnwindBondFlags();
}
if (!isUseApproval) {
this.getInterestList();
//this.refreshUnderlyingPrice();
@@ -299,6 +321,107 @@ const vue = new Vue({
thisObj.calcFloatClosePnl();
});
},
//==================================================================================
// EQD-6953 期末标的交割全价 ↔ 期末标的结算收益率(ExitYtm) 互算
// 交互对齐录入页(swapTradeEdit.js):回车=以该字段为源调 /Bond/CalcBond 反算对方;
// 失败=保留源字段、清对方、标识全清;手填未回车=REV 不联动(允许计算有问题时手工覆盖)。
// 关键差异见 unwindBondCalc.js 头注:平仓页两字段均为展示态(不 ×100/÷100),估值日=平仓日 ValueDate。
//==================================================================================
getYieldInputFormat() {
return swapPricePrecision.getInputFormat(
this.floatPosition && this.floatPosition.UnderlyingInstrumentType,
'yield',
inputFormatUnwindExitYtm);
},
//期末交割全价的 input(失焦/回车都会触发):保持既有盈亏联动;债券腿按交互约定3标 REV。
onEndDeliveryPriceInput() {
this.changeUnderlyingPrice();
if (this.isBondUnwindLeg) {
UnwindBondCalc.applyManualEdit(this.floatPosition, 'DP');
this.syncUnwindBondFlags();
}
},
//收益率字段编辑(失焦/回车触发,组件内部回车时 enter 随后修正标识):约定3 标 REV 不联动。
onEndBondPriceEdit(type) {
if (!this.isBondUnwindLeg) return;
UnwindBondCalc.applyManualEdit(this.floatPosition, type);
this.syncUnwindBondFlags();
},
//按键实时标 REV(组件 input 事件只在失焦/回车触发,逐键输入期间靠 keydown 清 源/AUTO)。
//回车/修饰键/导航键/功能键不改标识(SwapCalc.isBondPriceValueKey 白名单)。
onEndBondPriceKeydown(type, event) {
if (!this.isBondUnwindLeg) return;
if (!SwapCalc.isBondPriceValueKey(event)) return;
UnwindBondCalc.applyManualEdit(this.floatPosition, type);
this.syncUnwindBondFlags();
},
//回车=以该字段为源调计算器反算对方字段。
onEndBondPriceEnter(type) { // type: 'DP'期末交割全价 / 'YD'期末结算收益率
if (!this.isBondUnwindLeg) return;
if (!this.floatPosition.UnderlyingCode) { main.message("请先选择债券标的"); return; }
var price = type === 'DP' ? this.floatPosition.TradingAmountAvg : this.floatPosition.ExitYtm;
if (!UnwindBondCalc.hasValue(price)) return; // 空/非数:不触发计算器
this.calcUnwindBond(type);
},
//以 driver(回车字段)为源调 /Bond/CalcBond。成败路由见 base/main.js __post:业务错误走 reject
//失败落地必须在 .fail(录入页曾把失败处理只写 .done 导致静默失效,勿重蹈)。
calcUnwindBond(driver) {
if (!this.isBondUnwindLeg) return;
var fp = this.floatPosition;
fp.bondDriverType = driver; // 先定源,回写时 applyCalcResult 会跳过该字段
var price = driver === 'DP' ? fp.TradingAmountAvg : fp.ExitYtm;
if (!UnwindBondCalc.hasValue(price)) {
UnwindBondCalc.applyCalcFailure(fp, driver);
this.syncUnwindBondFlags();
return;
}
if (!this.deal.ValueDate) {
main.message("请先填写平仓日期(作为债券计算器估值日)");
UnwindBondCalc.applyCalcFailure(fp, driver);
this.syncUnwindBondFlags();
return;
}
var req = UnwindBondCalc.getCalcRequest(fp.UnderlyingCode, price, driver, this.deal.ValueDate);
otcDebug.log('[unwindBondCalc] enter driver=' + driver + ' req=' + JSON.stringify(req));
var self = this;
main.post('/Bond/CalcBond', req, { alertFn: main.message }).done(function (resp) {
if (!resp || !resp.obj) { // 网络错误/响应异常:框架已提示,按约定2清对方字段+标识
UnwindBondCalc.applyCalcFailure(fp, driver);
self.syncUnwindBondFlags();
return;
}
var err = SwapCalc.getBondCalcErrorMessage(resp.obj);
if (err) { // 业务错误(债券不存在/信息不全/参数非法/值域离谱):toast 去重提示,不回写
if (SwapCalc.shouldShowBondErr(fp, err)) main.message(err);
UnwindBondCalc.applyCalcFailure(fp, driver);
self.syncUnwindBondFlags();
otcDebug.log('[unwindBondCalc] calc-error driver=' + driver + ' msg=' + err);
return;
}
fp._lastBondErr = null;
UnwindBondCalc.applyCalcResult(fp, resp.obj, driver);
UnwindBondCalc.applyCalcSuccess(fp, driver);
self.syncUnwindBondFlags();
// YD 为源时 TradingAmountAvg 被反算回填,直接赋值不触发 input 事件,须显式重算盈亏
if (driver === 'YD') self.calcFloatClosePnl();
otcDebug.log('[unwindBondCalc] success driver=' + driver +
' ExitYtm=' + fp.ExitYtm + ' TradingAmountAvg=' + fp.TradingAmountAvg +
' auto=' + JSON.stringify(fp.bondAuto));
}).fail(function () {
UnwindBondCalc.applyCalcFailure(fp, driver);
self.syncUnwindBondFlags();
otcDebug.log('[unwindBondCalc] post-fail/reject driver=' + driver);
});
},
//确保 bondDriverType/bondAuto/bondRev 的变更触发 Vue 2 响应式更新(同录入页 syncBondFlags
//这些属性不在后端模型里,$set + 全新对象引用 + $forceUpdate 兜底,缺一视图不刷新)。
syncUnwindBondFlags() {
var fp = this.floatPosition;
this.$set(fp, 'bondDriverType', fp.bondDriverType === undefined ? null : fp.bondDriverType);
this.$set(fp, 'bondAuto', { DP: !!(fp.bondAuto && fp.bondAuto.DP), YD: !!(fp.bondAuto && fp.bondAuto.YD) });
this.$set(fp, 'bondRev', { DP: !!(fp.bondRev && fp.bondRev.DP), YD: !!(fp.bondRev && fp.bondRev.YD) });
this.$forceUpdate();
},
calcFloatClosePnl() {//计算浮动端平仓盈亏
var thisObj = this;
let floatRatio = thisObj.floatPosition.PayDirection == 1 ? 1 : -1;