fix 部分平仓 选择切换 会导致不一致,浮动端会错误

This commit is contained in:
hjhan
2026-07-16 20:57:18 +08:00
parent cd9fed9913
commit 62c26bd7f5
3 changed files with 104 additions and 7 deletions
+60
View File
@@ -101,6 +101,66 @@ describe('多次部分平仓:比例↔名义本金换算以 PosiNotionalValue
});
});
// ============================================================================
// 多次部分平仓:全部↔部分切换时 CloseQty 不应随 ClosePercent 口径变化而跳变
// 守卫 unwindSwapTrade.js changeCloseMethod/changeClosePercent/changeCloseQty/changeCloseNotionalValue
// 旧 bugClosePercent 是占期初口径(A),但 CloseQty=PositionQty*ClosePercent 错当占剩余(B)用
// 全部(ClosePercent=0.7,CloseQty=720000) → 部分(ClosePercent没变, CloseQty=504000) ❌ 跳变
// 修复:CloseQty = PositionQty × (ClosePercent / oriClosePercent)
// ============================================================================
describe('多次部分平仓:全部↔部分切换 CloseQty 不跳变(占期初口径自洽)', () => {
// 场景基于 GLMS-20260701-0013
// 期初 NotionalValue=980000,已平两次剩 PosiNotionalValue=686000, PositionQty=720000
// oriClosePercent = 686000/980000 = 0.7
const NotionalValue = 980000;
const PosiNotionalValue = 686000;
const PositionQty = 720000;
const oriClosePercent = PosiNotionalValue / NotionalValue; // 0.7
test('全部平仓 ClosePercent=0.7 → CloseQty=720000(剩余全部)', () => {
const closePercent = oriClosePercent; // 全部平仓 = oriClosePercent
const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, PositionQty);
expectClose(closeQty, 720000, '全部平仓应平剩余全部数量');
});
test('全部→部分切换(ClosePercent 没变=0.7):CloseQty 应保持 720000 不跳变', () => {
// 旧 bugCloseQty = 720000 * 0.7 = 504000(错误跳变)
// 修复:CloseQty = 720000 * (0.7/0.7) = 720000(不变)
const closePercent = oriClosePercent;
const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, PositionQty);
const buggyCloseQty = Math.round(PositionQty * closePercent * 100) / 100; // 旧 bug 公式
expectClose(closeQty, 720000, '修复后应不变');
expect(Math.abs(buggyCloseQty - 504000)).toBeLessThanOrEqual(TOL, '旧 bug 会跳变到 504000');
expect(Math.abs(closeQty - buggyCloseQty)).toBeGreaterThan(TOL, '修复与旧 bug 必须有差异');
});
test('改比例 0.5(占期初)→ CloseQty=720000×(0.5/0.7)=514285.71', () => {
const closePercent = 0.5;
const closeQty = SwapCalc.calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, PositionQty);
expectClose(closeQty, 514285.71, '占期初 50% 对应占剩余 71.43%,×720000=514285.71');
});
test('逆运算:CloseQty=514285.71 → ClosePercent=0.5(占期初)', () => {
const closeQty = 514285.71;
const closePercent = SwapCalc.calcOriginalClosePercentByQty(closeQty, PositionQty, oriClosePercent);
expectClose(closePercent, 0.5, '逆运算应还原为占期初 0.5');
});
test('逆运算自洽:CloseQty=720000=PositionQty)→ ClosePercent=0.7=oriClosePercent', () => {
const closeQty = PositionQty; // 720000
const closePercent = SwapCalc.calcOriginalClosePercentByQty(closeQty, PositionQty, oriClosePercent);
expectClose(closePercent, oriClosePercent, '平全部剩余 → ClosePercent=oriClosePercent');
});
test('除零保护:oriClosePercent=0(已全平完)→ CloseQty=0', () => {
expect(SwapCalc.calcCloseQtyByOriginalPercent(0.5, 0, 720000)).toBe(0);
});
test('除零保护:PositionQty=0 → ClosePercent=0', () => {
expect(SwapCalc.calcOriginalClosePercentByQty(100, 0, oriClosePercent)).toBe(0);
});
});
describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', () => {
// FC_001 平仓-债券多头-默认
test('FC_001 平仓 债券多头 默认', () => {
@@ -78,6 +78,26 @@
return roundHalfAwayFromZero(Number(closeNotionalValue) / Number(posiNotionalValue), 6);
}
// 平仓数量(占期初口径 A):CloseQty = PositionQty × (closePercent / oriClosePercent)
// closePercent 是"占期初名义本金比例"(A),需先除以 oriClosePercent(=剩余/期初) 转成"占剩余比例"(B)
// 再乘以剩余持仓数量 PositionQty。
// 多次部分平仓后必须这样转换,否则全部↔部分切换时 ClosePercent 没变但 CloseQty 会变(不自洽)。
// 除零保护:oriClosePercent=0(剩余为0,已全部平完)时返回 0。
function calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, positionQty) {
var ori = Number(oriClosePercent);
if (ori === 0) return 0;
return roundHalfAwayFromZero(Number(positionQty) * (Number(closePercent) / ori), 2);
}
// 平仓比例(占期初口径 A= (CloseQty / PositionQty) × oriClosePercent
// CloseQty/PositionQty 得到"占剩余比例"(B),乘以 oriClosePercent(=剩余/期初) 转成"占期初比例"(A)。
// 除零保护:PositionQty=0 时返回 0。
function calcOriginalClosePercentByQty(closeQty, positionQty, oriClosePercent) {
var qty = Number(positionQty);
if (qty === 0) return 0;
return roundHalfAwayFromZero((Number(closeQty) / qty) * Number(oriClosePercent), 6);
}
// 盯市平仓盈亏(unwind):CloseQty × (期末全价×scale 期初全价) × floatRatio × longRatio
// 对齐 FrontendCalcReference.CalcUnwind:先 ×10000 取整再 ÷10000,最后 toFixed(2)
// 干净输入下等价于直接 round(.., 2)
@@ -179,6 +199,8 @@
calcStockEqvNotional: calcStockEqvNotional,
calcCloseNotionalByRemaining: calcCloseNotionalByRemaining,
calcClosePercentByRemaining: calcClosePercentByRemaining,
calcCloseQtyByOriginalPercent: calcCloseQtyByOriginalPercent,
calcOriginalClosePercentByQty: calcOriginalClosePercentByQty,
calcMarkClosePnl: calcMarkClosePnl,
calcUnwind: calcUnwind,
calcIncome: calcIncome
@@ -131,12 +131,20 @@ const vue = new Vue({
this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.PosiNotionalValue));
this.deal.CloseQty = this.deal.PositionQty;
} else {
this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent));
// ClosePercent 是占期初口径(A),需除以 oriClosePercent 转占剩余(B) 再乘剩余数量
this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent);
}
this.calcTradingFeePending();
this.getInterestList();
this.calcFloatClosePnl();
},
// 按"占期初口径(A)"的 ClosePercent 反算平仓数量:CloseQty = PositionQty × (ClosePercent / oriClosePercent)
// 多次部分平仓后必须这样转换,否则全部↔部分切换时 ClosePercent 没变但 CloseQty 会变(不自洽)
calcCloseQtyByPercent(closePercent) {
var ori = parseFloat(this.oriClosePercent) || 0;
var ratio = ori > 0 ? parseFloat(closePercent) / ori : 0;
return otcformat.trading.notional(parseFloat(this.deal.PositionQty) * ratio);
},
calcTradingFeePending() {
this.floatPosition.TradingFeePending = this.floatPosition.BeforeCloseFee * parseFloat(this.deal.ClosePercent);
},
@@ -145,7 +153,9 @@ const vue = new Vue({
main.message("平仓数量不能超过持仓数量");
return;
}
this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseQty) / parseFloat(this.deal.PositionQty));
// CloseQty/PositionQty 得占剩余(B),× oriClosePercent 转回占期初(A)
var ori = parseFloat(this.oriClosePercent) || 0;
this.deal.ClosePercent = otcformat.fixed6((parseFloat(this.deal.CloseQty) / parseFloat(this.deal.PositionQty)) * ori);
if (parseFloat(this.deal.CloseQty) == parseFloat(this.deal.PositionQty)) {
this.deal.CloseMethod = 1;
} else {
@@ -163,13 +173,13 @@ const vue = new Vue({
this.deal.ClosePercent = this.oriClosePercent;
return;
}
this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent));
this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent);
// 占期初口径:平仓名义本金 = 平仓比例 × 期初名义本金(NotionalValue)
this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue));
if (parseFloat(this.deal.CloseNotionalValue) == parseFloat(this.deal.PosiNotionalValue)) {
this.floatPosition.CloseMethod = 1;
if (parseFloat(this.deal.ClosePercent) == parseFloat(this.oriClosePercent)) {
this.deal.CloseMethod = 1;
} else {
this.floatPosition.CloseMethod = 2;
this.deal.CloseMethod = 2;
}
this.calcTradingFeePending();
this.getInterestList();
@@ -183,7 +193,12 @@ const vue = new Vue({
}
// 占期初口径:平仓比例 = 平仓名义本金 / 期初名义本金(NotionalValue)
this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseNotionalValue) / parseFloat(this.deal.NotionalValue));
this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent));
this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent);
if (parseFloat(this.deal.ClosePercent) == parseFloat(this.oriClosePercent)) {
this.deal.CloseMethod = 1;
} else {
this.deal.CloseMethod = 2;
}
this.calcTradingFeePending();
this.getInterestList();
this.calcFloatClosePnl();