修复收益互换多次部分平仓预付金返还:平仓比例/名义本金改以剩余持仓为基准
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:
@@ -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'); }
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user