Merge branch 'glms/feature/1.4.2' of http://git.yiliantech.com/gitlab/otc-dev/zszq-trs into glms/feature/1.4.2

This commit is contained in:
锦麟 王
2026-07-16 10:04:40 +08:00
6 changed files with 100 additions and 13 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();
@@ -432,7 +432,6 @@
function __onInput() {
if (_ctrlV) {
_ctrlV = false;
console.log('[numberInput paste] raw=', this.value, 'append=', _options.append, 'percent=', _options.percent);
// 粘贴进来的是显示值(可能带 %/‱ 后缀),先按 __change 同款口径解析为模型值再回显,
// 避免 setValue 把显示值再乘以 100/10000#EQD-5914 债券价格粘贴 ×100
let f = '';
@@ -441,7 +440,6 @@
if (_options.append === '%' || _options.percent == true) f /= 100;
else if (_options.append === '‱') f /= 10000;
}
console.log('[numberInput paste] parsed=', f);
return setValue(f);
}
if (_chnInput >= 0) {
@@ -464,7 +462,6 @@
//输入完成处理
function __change() {
console.log('[numberInput change] raw=', this.value, 'append=', _options.append, 'percent=', _options.percent);
let f = '';
if (this.value && this.value !== _options.append) {
f = parseFloat(this.value.replaceAll(",", "")) || 0;
@@ -472,7 +469,6 @@
else if (_options.append === '‱') f /= 10000;
}
console.log('[numberInput change] parsed=', f, 'value=', this.value);
_options.onchange && _options.onchange(f, this.value);
}
File diff suppressed because one or more lines are too long