refactor(web): 平仓页收付方向符号显式化unwindLegSign.js+4例单测——裸三元(x==1?±1:∓1)抽为命名函数并冻结业务事实:利息盈亏与保证金返还本金对同一InterestDirection枚举必须反号(利息=买方持有标的向交易商融资的成本,保证金返还=退客户自己交的钱,业务确认非笔误),不变量断言两符号之和恒0,顺手统一必挂;interestPnlSign对利息/保证金两腿通用(changeInterestAmount模板对两类行都绑定,已核实);另删死代码:this.ratio/this.shortRatio数据属性+赋值全库无读取(模板亦无绑定)及注释掉的历史三元;SwapUnwind.cshtml补script标签,两个执行型测试沙箱喂真实模块;行为等价(Number===1与==1对实际入参等价已验证),全套315例绿

This commit is contained in:
hjhan
2026-08-21 07:17:20 +08:00
parent 6e76f1c10d
commit e8d0a76442
6 changed files with 104 additions and 11 deletions
@@ -27,6 +27,7 @@
<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/unwindLegSign.js?v=@HtmlUtil.JsVersion"></script>
<script src="~/Scripts/app/swaptrade/unwindSwapTrade.js?v=@HtmlUtil.JsVersion"></script>
}
<div class="pb-3" id="vueDiv">
@@ -155,6 +155,8 @@ function loadVueApp(model, mockPost) {
roundExitYtm(v) { return v; },
applyManualEdit(s) { return s; }
},
// 收付方向符号:纯函数零依赖,直接喂真实模块(映射冻结由 unwindLegSign.test.js 覆盖)
UnwindLegSign: require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'),
_: {
round(value, precision) {
return Number(Number(value || 0).toFixed(precision || 0));
+47
View File
@@ -0,0 +1,47 @@
/**
* unwindLegSign.test.js — 平仓页收付方向符号映射(隐式约定显式化)
* ============================================================================
* 冻结的领域事实(2026-08-21 业务确认):
* 利息腿(融资成本)与保证金腿(返息/返还本金)方向【必须相反】——
* 利息是买方持有标的向交易商融资的成本(买方付出去的钱);
* 保证金是客户自己交的抵押金,返息/返还是把客户自己的钱退回来。
* 两者对同一 InterestDirection 枚举符号互为镜像,这是业务事实而非笔误,
* 任何人"顺手统一"这两个符号都会翻转保证金返还方向(资损级 bug)。
*
* 另冻结:PayDirection==1 → +1、PositionType==1(多头) → +1 的浮动端/多空符号。
*
* 运行:cd YLErpWeb/fe-tests && npx jest unwindLegSign
*/
const UnwindLegSign = require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js');
describe('收付方向符号:各枚举映射', () => {
test('利息盈亏(利息腿/保证金腿通用):收取(1)→+1,支付(其他)→-1', () => {
expect(UnwindLegSign.interestPnlSign(1)).toBe(1);
expect(UnwindLegSign.interestPnlSign(0)).toBe(-1);
expect(UnwindLegSign.interestPnlSign(null)).toBe(-1);
});
test('保证金腿返还本金:与利息腿同枚举反号(返的是客户自己的钱)', () => {
expect(UnwindLegSign.marginRebatePrincipalSign(1)).toBe(-1);
expect(UnwindLegSign.marginRebatePrincipalSign(0)).toBe(1);
expect(UnwindLegSign.marginRebatePrincipalSign(null)).toBe(1);
});
test('浮动端收付:PayDirection==1(收取)→+1;多空:PositionType==1(多头)→+1', () => {
expect(UnwindLegSign.payDirectionSign(1)).toBe(1);
expect(UnwindLegSign.payDirectionSign(0)).toBe(-1);
expect(UnwindLegSign.positionTypeSign(1)).toBe(1);
expect(UnwindLegSign.positionTypeSign(0)).toBe(-1);
});
});
describe('不变量:利息腿与保证金腿符号互为镜像(业务事实,禁止统一)', () => {
test('同一 InterestDirection 下两符号之和恒为 0', () => {
[1, 0, null, undefined, 2].forEach(dir => {
expect(
UnwindLegSign.interestPnlSign(dir)
+ UnwindLegSign.marginRebatePrincipalSign(dir)
).toBe(0);
});
});
});
@@ -66,6 +66,8 @@ function loadUnwindHelpers() {
roundHalfAwayFromZero(value) { return value; },
calcCloseQtyByOriginalPercent() { return 0; }
},
// 收付方向符号:纯函数零依赖,直接喂真实模块(映射冻结由 unwindLegSign.test.js 覆盖)
UnwindLegSign: require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'),
_: {
round(value, precision) {
return Number(Number(value || 0).toFixed(precision || 0));
@@ -0,0 +1,45 @@
/**
* unwindLegSign.js — 平仓页收付方向符号(纯函数)
* ============================================================================
* 把散落在 unwindSwapTrade.js 各计算式里的裸三元(xxx==1 ? ±1 : ∓1)显式化为命名函数。
* 【为什么必须显式化】利息盈亏与保证金返还本金对同一 InterestDirection 枚举符号相反——
* 这是业务事实而非笔误,任何"顺手统一"都会翻转保证金返还方向(资损级 bug):
* 利息盈亏(利息腿/保证金腿通用,changeInterestAmount 对两类行都生效):
* 收益互换中买方为持有标的向交易商融资,利息=融资成本(买方付出去的钱),
* InterestDirection==1(收取) → 盈亏记 +1
* 保证金返还本金:保证金是客户自己交的抵押金,返息/返还本金是把客户自己的钱退回来,
* 本金流方向与利息(成本)相反 → 同枚举符号取镜像 -1。
*
* 其余两个符号:PayDirection==1(浮动端收取) → +1PositionType==1(多头) → +1。
*
* 加载:浏览器 script 标签(先于 unwindSwapTrade.js);Nodemodule.exports 供 jest。
*/
var UnwindLegSign = (function () {
'use strict';
// Number()===1 与页面原 ==1 对 '1'/true/数字等实际入参等价,且对 null/undefined 同样落到 -1 分支
function interestPnlSign(interestDirection) {
return Number(interestDirection) === 1 ? 1 : -1;
}
function marginRebatePrincipalSign(interestDirection) {
return Number(interestDirection) === 1 ? -1 : 1;
}
function payDirectionSign(payDirection) {
return Number(payDirection) === 1 ? 1 : -1;
}
function positionTypeSign(positionType) {
return Number(positionType) === 1 ? 1 : -1;
}
return Object.freeze({
interestPnlSign: interestPnlSign,
marginRebatePrincipalSign: marginRebatePrincipalSign,
payDirectionSign: payDirectionSign,
positionTypeSign: positionTypeSign
});
}());
if (typeof module === 'object' && module.exports) module.exports = UnwindLegSign;
@@ -69,8 +69,6 @@ const vue = new Vue({
// 平仓比例展示/输入均为"占期初(original)"语义(A):默认与每次重开都基于原始名义本金。
// oriClosePercent = 剩余名义本金/期初名义本金 = 最多可平比例(不能平超过剩余持仓)。
oriClosePercent: 1,
ratio: 1,
shortRatio: 1,
},
computed: {
maxUnwindDate() {
@@ -125,8 +123,6 @@ const vue = new Vue({
this.marginList = model.FlowEvents.filter((item) => {
return item.InterestMode == 5 || item.InterestMode == 6;
});
this.ratio = this.floatPosition.PayDirection == 1 ? -1 : 1;
this.shortRatio = this.floatPosition.PositionType == 1 ? 1 : -1;
this.TradeStartDate = model.TradeStartDate;
// 最多可平比例(占期初口径) = 剩余名义本金 / 期初名义本金;分母为 0 时兜底为 1
this.oriClosePercent = (this.deal.NotionalValue && this.deal.PosiNotionalValue)
@@ -424,8 +420,8 @@ const vue = new Vue({
},
calcFloatClosePnl() {//计算浮动端平仓盈亏
var thisObj = this;
let floatRatio = thisObj.floatPosition.PayDirection == 1 ? 1 : -1;
let longRatio = thisObj.floatPosition.PositionType == 1 ? 1 : -1;
let floatRatio = UnwindLegSign.payDirectionSign(thisObj.floatPosition.PayDirection);
let longRatio = UnwindLegSign.positionTypeSign(thisObj.floatPosition.PositionType);
let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee);
let TradingFeePending = thisObj.floatPosition.TradingFeePending == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFeePending);
let deliveryPrice = thisObj.getStorageDeliveryPrice();
@@ -440,7 +436,7 @@ const vue = new Vue({
this.calcFloatClosePnl();
},
changeInterestAmount(item) {//修改利息金额
let interestRatio = item.InterestDirection == 1 ? 1 : -1;
let interestRatio = UnwindLegSign.interestPnlSign(item.InterestDirection);
item.InterestClosePnL = formatSwapAmount(parseFloat(item.InterestAmount) * interestRatio + parseFloat(item.InterestFee) * interestRatio);
this.calcCloseAmount();
},
@@ -450,8 +446,8 @@ const vue = new Vue({
// this.calcCloseAmount();
//},
calcCloseAmount() {//计算平仓总额=浮动收取+利息收取-浮动支付-利息支付
let floatRatio = this.floatPosition.PayDirection == 1 ? 1 : -1;
let ratio = this.floatPosition.PositionType == 1 ? 1 : -1;
let floatRatio = UnwindLegSign.payDirectionSign(this.floatPosition.PayDirection);
let ratio = UnwindLegSign.positionTypeSign(this.floatPosition.PositionType);
let thisObj = this;
let pnl = parseFloat(this.floatPosition.FloatPnlSum);
let TradingFee = thisObj.floatPosition.TradingFee == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFee);
@@ -468,14 +464,14 @@ const vue = new Vue({
thisObj.floatPosition.TradingAmountFeeAvg = deliveryPrice + (TradingFee / thisObj.deal.CloseQty) * ratio;
}
this.interestList.forEach(x => {
/*let interestRatio = x.InterestDirection == 1 ? 1 : -1;*/
let interestAmount = parseFloat(x.InterestClosePnL);
thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount;
thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount;
});
this.marginList.forEach(x => {
let interestAmount = parseFloat(x.InterestClosePnL);
let interestRatio = x.InterestDirection == 1 ? -1 : 1;
// 保证金返还本金符号与利息盈亏同枚举反号(见 unwindLegSign.js 头注——业务事实勿统一)
let interestRatio = UnwindLegSign.marginRebatePrincipalSign(x.InterestDirection);
thisObj.deal.SwapCloseAmount = parseFloat(thisObj.deal.SwapCloseAmount) + interestAmount;
thisObj.deal.SwapMarginRebatePnl = parseFloat(thisObj.deal.SwapMarginRebatePnl) + interestAmount;
thisObj.deal.SwapRealizedPnL = parseFloat(thisObj.deal.SwapRealizedPnL) + interestAmount;