修复收益互换多次部分平仓预付金返还:平仓比例/名义本金改以剩余持仓为基准

unwindSwapTrade.js:
- oriClosePercent 恒为 1(Definition B 下最多平 100% 剩余,修正全部平仓/按比例少返预付金 + 进页面 getInterestList 少算)
- CloseMethod==1(全部平仓)时修正 ClosePercent=1;CloseMethod==2(部分平仓/待复核)保留已提交比例,避免覆盖
- changeCloseQty/Percent/NotionalValue 三处换算改用 PosiNotionalValue(剩余本金),不再用原始 NotionalValue

swapCalc.js: 新增 calcCloseNotionalByRemaining / calcClosePercentByRemaining 纯函数(含除零保护)
swapCalc.test.js + _shim_run.js: 补充多次部分平仓换算单测(剩余vs原始对比、第3次全平、3次合计=原始、除零保护)
This commit is contained in:
hjhan
2026-07-16 10:03:21 +08:00
parent d69faaa8e8
commit 4f46e2634f
4 changed files with 92 additions and 4 deletions
+4
View File
@@ -24,6 +24,10 @@ function makeExpect(actual) {
if (actual <= n) { passed++; }
else { failed++; failures.push('toBeLessThanOrEqual: ' + fmt(actual) + ' <= ' + n + ' failed'); }
},
toBeGreaterThan(n) {
if (actual > n) { passed++; }
else { failed++; failures.push('toBeGreaterThan: ' + fmt(actual) + ' > ' + n + ' failed'); }
},
toBeGreaterThanOrEqual(n) {
if (actual >= n) { passed++; }
else { failed++; failures.push('toBeGreaterThanOrEqual: ' + fmt(actual) + ' >= ' + n + ' failed'); }
+58
View File
@@ -43,6 +43,64 @@ describe('回归守卫:曾出 bug 的纯函数', () => {
});
});
// ============================================================================
// 多次部分平仓:平仓比例 ↔ 平仓名义本金 换算必须以"剩余持仓名义本金"为基准
// 守卫 unwindSwapTrade.js changeCloseQty/changeClosePercent/changeCloseNotionalValue
// 旧 bug:用原始 NotionalValue 换算,首次部分平仓后 PosiNotionalValue 被扣减,
// 导致 ClosePercent 偏小 / CloseNotionalValue 偏大,后端预付金返还本金计算错误
// ============================================================================
describe('多次部分平仓:比例↔名义本金换算以 PosiNotionalValue 为基准', () => {
// 场景:原始名义本金 1,000,000,首次平 30% 后剩余 700,000
// 第 2 次按名义本金输入 350,000(意图平剩余 50%
const NotionalValue = 1_000_000; // 原始(不变)
const PosiNotionalValue = 700_000; // 首次部分平仓后剩余
test('按名义本金反算比例:应=0.5(用剩余),旧 bug 会算成 0.35(用原始)', () => {
const closeNotionalValue = 350_000;
const correctPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, PosiNotionalValue);
const buggyPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, NotionalValue);
expectClose(correctPct, 0.5, '剩余本金为分母应得 50%');
expectClose(buggyPct, 0.35, '旧 bug 用原始本金会算成 0.35');
expect(Math.abs(correctPct - buggyPct)).toBeGreaterThan(TOL);
});
test('按比例正算名义本金:应=350,000(用剩余),旧 bug 会算成 500,000(用原始)', () => {
const closePercent = 0.5;
const correctNotional = SwapCalc.calcCloseNotionalByRemaining(closePercent, PosiNotionalValue);
const buggyNotional = SwapCalc.calcCloseNotionalByRemaining(closePercent, NotionalValue);
expectClose(correctNotional, 350_000, '剩余本金×50%应=350,000');
expectClose(buggyNotional, 500_000, '旧 bug 用原始本金会算成 500,000');
expect(Math.abs(correctNotional - buggyNotional)).toBeGreaterThan(TOL);
});
test('第 3 次全平剩余 350,000:比例应=1.0(用剩余),旧 bug 会算成 0.35', () => {
const remaining = 350_000;
const closeNotionalValue = 350_000;
const correctPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, remaining);
const buggyPct = SwapCalc.calcClosePercentByRemaining(closeNotionalValue, NotionalValue);
expectClose(correctPct, 1.0, '剩余全平应=100%');
expectClose(buggyPct, 0.35, '旧 bug 用原始本金会算成 0.35');
});
test('多次部分平仓合计本金应=原始名义本金(3 次:30% / 50% / 全平)', () => {
// 模拟 3 次部分平仓的换算,每次以"当前剩余"为基准
let remaining = 1_000_000;
const steps = [0.3, 0.5, 1.0];
let totalClosed = 0;
steps.forEach((pct) => {
const closed = SwapCalc.calcCloseNotionalByRemaining(pct, remaining);
totalClosed += closed;
remaining -= closed;
});
expectClose(totalClosed, 1_000_000, '3 次部分平仓合计应=原始名义本金');
expectClose(remaining, 0, '剩余应为 0');
});
test('除零保护:剩余本金为 0 时比例返回 0', () => {
expect(SwapCalc.calcClosePercentByRemaining(100, 0)).toBe(0);
});
});
describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', () => {
// FC_001 平仓-债券多头-默认
test('FC_001 平仓 债券多头 默认', () => {
@@ -65,6 +65,19 @@
return roundHalfAwayFromZero(posiGrossPrice * factor, 2);
}
// 平仓名义本金 = 平仓比例 × 剩余持仓名义本金(PosiNotionalValue
// 多次部分平仓后必须用剩余本金 PosiNotionalValue,不能用原始 NotionalValue,否则偏大
function calcCloseNotionalByRemaining(closePercent, posiNotionalValue) {
return roundHalfAwayFromZero(Number(closePercent) * Number(posiNotionalValue), 2);
}
// 平仓比例 = 平仓名义本金 / 剩余持仓名义本金(PosiNotionalValue
// 多次部分平仓后必须以剩余本金为分母,否则比例偏小,导致后端预付金返还本金计算错误
function calcClosePercentByRemaining(closeNotionalValue, posiNotionalValue) {
if (Number(posiNotionalValue) === 0) return 0;
return roundHalfAwayFromZero(Number(closeNotionalValue) / Number(posiNotionalValue), 6);
}
// 盯市平仓盈亏(unwind):CloseQty × (期末全价×scale 期初全价) × floatRatio × longRatio
// 对齐 FrontendCalcReference.CalcUnwind:先 ×10000 取整再 ÷10000,最后 toFixed(2)
// 干净输入下等价于直接 round(.., 2)
@@ -164,6 +177,8 @@
roundMoney: roundMoney,
calcFloatPnlSum: calcFloatPnlSum,
calcStockEqvNotional: calcStockEqvNotional,
calcCloseNotionalByRemaining: calcCloseNotionalByRemaining,
calcClosePercentByRemaining: calcClosePercentByRemaining,
calcMarkClosePnl: calcMarkClosePnl,
calcUnwind: calcUnwind,
calcIncome: calcIncome
@@ -17,7 +17,9 @@ const vue = new Vue({
marginList: [],
initPosiNetPrice: 0,
multiplier: 1,
oriClosePercent: model.ClosePercent,
// 多次部分平仓后,ClosePercent 语义为“占剩余持仓的比例”(后端 GetUnwindInterests 用 remainingBase×closePercent 计算预付金返还)
// 故最多可平 100% 剩余,oriClosePercent 恒为 1;不能用 model.ClosePercent(=剩余/原始,Definition A 旧口径),否则全部平仓/按比例会少返预付金
oriClosePercent: 1,
ratio: 1,
shortRatio: 1,
},
@@ -53,6 +55,12 @@ const vue = new Vue({
this.ratio = this.floatPosition.PayDirection == 1 ? -1 : 1;
this.shortRatio = this.floatPosition.PositionType == 1 ? 1 : -1;
this.TradeStartDate = model.TradeStartDate;
// 多次部分平仓后 ClosePercent 语义为"占剩余持仓比例"。
// 仅当"全部平仓"(CloseMethod==1) 时修正旧口径(model.ClosePercent 可能=剩余/原始<1)为 1
// "部分平仓"(CloseMethod==2) 时保留已提交比例(平仓待复核场景),避免覆盖用户已提交的 closePercent
if (this.deal.CloseMethod === 1) {
this.deal.ClosePercent = 1;
}
// 转换期末标的价格为百分比形式
if (this.floatPosition.TradingAmountAvg) {
this.floatPosition.TradingAmountAvg = this.floatPosition.TradingAmountAvg * this.multiplier;
@@ -146,7 +154,8 @@ const vue = new Vue({
} else {
this.deal.CloseMethod = 2;
}
this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue));
// 多次部分平仓后 PosiNotionalValue 才是剩余本金,不能用原始 NotionalValue,否则平仓名义本金偏大
this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.PosiNotionalValue));
this.calcTradingFeePending();
this.getInterestList();
this.calcFloatClosePnl();
@@ -158,7 +167,8 @@ const vue = new Vue({
return;
}
this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent));
this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue));
// 多次部分平仓后 PosiNotionalValue 才是剩余本金,不能用原始 NotionalValue,否则平仓名义本金偏大
this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.PosiNotionalValue));
if (parseFloat(this.deal.CloseNotionalValue) == parseFloat(this.deal.PosiNotionalValue)) {
this.floatPosition.CloseMethod = 1;
} else {
@@ -174,7 +184,8 @@ const vue = new Vue({
this.deal.CloseNotionalValue = this.deal.PosiNotionalValue;
return;
}
this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseNotionalValue) / parseFloat(this.deal.NotionalValue));
// 多次部分平仓后应以 PosiNotionalValue(剩余) 为分母,否则 ClosePercent 偏小,导致后端预付金返还本金计算错误
this.deal.ClosePercent = otcformat.fixed6(parseFloat(this.deal.CloseNotionalValue) / parseFloat(this.deal.PosiNotionalValue));
this.deal.CloseQty = otcformat.trading.notional(parseFloat(this.deal.PositionQty) * parseFloat(this.deal.ClosePercent));
this.calcTradingFeePending();
this.getInterestList();