#EQD-6597 国联民生-簿记时新增基本费率字段(开平仓费用),并且自动计算

feat: 完善互换平仓交易费用自动计算

- 支持百分比和单位数量模式自动计算平仓交易费用
- 支持全部平仓和部分平仓联动重算
- 保持手动互换不自动填充平仓交易费用
- 补充前后端相关单测
This commit is contained in:
tengyufan
2026-07-27 11:57:20 +08:00
parent a575de7bbe
commit 4987962aca
10 changed files with 329 additions and 17 deletions
@@ -228,6 +228,16 @@ namespace YLErp.DBModels
[NotMapped]
public decimal BeforeCloseFee { get; set; }
/// <summary>
/// 基础费率(仅前端展示,不存库)
/// </summary>
[NotMapped]
public decimal PosiTradingFeeUnit { get; set; }
/// <summary>
/// 基础费率模式 0=百分比 1=单位数量(仅前端展示,不存库)
/// </summary>
[NotMapped]
public int PosiFeeType { get; set; }
/// <summary>
/// 持仓腿id
/// </summary>
[DisplayName("持仓腿id")]
@@ -113,6 +113,11 @@ namespace YLErp.DBModels
[DataChange]
public decimal PosiTradingFeeUnit { get; set; }
/// <summary>
/// 单位交易费用模式 0=百分比 1=单位数量
/// </summary>
[DataChange]
public int PosiFeeType { get; set; }
/// <summary>
/// 起始日
/// </summary>
[DisplayName("起始日")]
@@ -0,0 +1,65 @@
using System.Reflection;
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
{
[TestClass]
public class InitUnwindTradingFeeTest
{
private static decimal InvokeCalcInitTradingFee(swap_position position, UnwindData unwindData)
{
var method = typeof(SwapDealService).GetMethod(
"CalcInitTradingFee",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(method, "未找到 CalcInitTradingFee 私有静态方法");
return (decimal)method.Invoke(null, new object[] { position, unwindData });
}
[TestMethod]
public void _按平仓名义本金计算并四舍五入到两位()
{
var position = new swap_position
{
PosiFeeType = 0,
PosiTradingFeeUnit = 0.1234m
};
var unwindData = new UnwindData
{
CloseNotionalValue = 1_000_000m,
CloseQty = 8888m
};
var fee = InvokeCalcInitTradingFee(position, unwindData);
Assert.AreEqual(1234.00m, fee);
}
[TestMethod]
public void _按平仓数量计算并四舍五入到两位()
{
var position = new swap_position
{
PosiFeeType = 1,
PosiTradingFeeUnit = 1.235m
};
var unwindData = new UnwindData
{
CloseNotionalValue = 1_000_000m,
CloseQty = 10m
};
var fee = InvokeCalcInitTradingFee(position, unwindData);
Assert.AreEqual(12.35m, fee);
}
[TestMethod]
public void _返回零()
{
Assert.AreEqual(0m, InvokeCalcInitTradingFee(null, new UnwindData()));
Assert.AreEqual(0m, InvokeCalcInitTradingFee(new swap_position(), null));
}
}
}
@@ -291,6 +291,9 @@ namespace YLErp.Modules.SwapModule
floatEvent.UnderlyingInstrumentType = position.UnderlyingInstrumentType;
floatEvent.CloseFee = 0;
floatEvent.BeforeCloseFee = oriPosition.PosiTradingFeePending;
floatEvent.TradingFee = CalcInitTradingFee(oriPosition, unwindData);
floatEvent.PosiTradingFeeUnit = oriPosition?.PosiTradingFeeUnit ?? 0;
floatEvent.PosiFeeType = oriPosition?.PosiFeeType ?? 0;
floatEvent.MarkClosePnl = 0;
floatEvent.PayDirection = position.PosiDirection;
floatEvent.PosiGrossPrice = position.PosiGrossPrice;
@@ -313,6 +316,20 @@ namespace YLErp.Modules.SwapModule
}
return unwindData;
}
private static decimal CalcInitTradingFee(swap_position oriPosition, UnwindData unwindData)
{
if (oriPosition == null || unwindData == null)
{
return 0;
}
if (oriPosition.PosiFeeType == 1)
{
return Math.Round(oriPosition.PosiTradingFeeUnit * unwindData.CloseQty, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
return Math.Round(oriPosition.PosiTradingFeeUnit / 100m * unwindData.CloseNotionalValue, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
}
/// <summary>
/// 校验上日是否收盘
/// </summary>
@@ -1375,6 +1375,7 @@ namespace YLErp.Modules.SwapModule
position.PosiTradingFee = swap.PosiTradingFee;
position.PosiTradingFee=Math.Round(position.PosiTradingFee, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
position.PosiTradingFeeUnit = swap.PosiTradingFeeUnit;
position.PosiFeeType = swap.PosiFeeType;
position.PosiTradingFeePending = swap.PosiTradingFeePending;
position.PosiTradingFeePending = Math.Round(position.PosiTradingFeePending, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
position.UnderlyingCode = swap.UnderlyingCode;
@@ -450,6 +450,7 @@
<th v-if="trade.StructureType!='普通收益互换'">期初标的成交收益率%</th>
<th v-if="trade.StructureType=='普通收益互换'">期初标的价格</th>
<th>数量</th>
<th>基础费率</th>
<th>交易费用后付</th>
</tr>
<tr class="swapflowtr" v-for="item in paySwapList">
@@ -487,6 +488,16 @@
<td>
<vue-number-input v-model="item.PosiQuantity" v-on:input="changeQuantity(item)" v-bind:format="inputFormatTradeAmount"></vue-number-input>{{item.underlying!=null?item.underlying.QuoteUnitString:''}}
</td>
<td>
<template v-if="posiFeeModePercent">
<vue-number-input v-model="item.PosiTradingFeeUnit" v-on:input="changeTradingFeeUnit(item)" v-bind:format="inputFormatPosiFeePercent"></vue-number-input>
<a href="javascript:;" title="点击后切换成单位数量模式" v-on:click="showPayAbsPrice" class="yt-input-group-append" tabindex="-1">%</a>
</template>
<template v-else>
<vue-number-input v-model="item.PosiTradingFeeUnit" v-on:input="changeTradingFeeUnit(item)" v-bind:format="inputFormatPosiFeeUnit"></vue-number-input>
<a href="javascript:;" title="点击后切换成百分比模式" v-on:click="showPayPercentPrice" class="yt-input-group-append" tabindex="-1">¥</a>
</template>
</td>
<td>
<vue-number-input v-model="item.PosiTradingFeePending" v-on:input="changeTradingFee(item)" v-bind:format="inputFormatTradeSinglePrice"></vue-number-input>
<div class="bubble-box">我方{{item.PosiDirection==1?"支付":"收取"}}交易费用</div>
@@ -384,6 +384,7 @@
<td>@initYtmTitle</td>
}
<td>数量</td>
<td>基础费率</td>
<td>交易费用后付</td>
</tr>
@foreach (var item in paySwapPositions)
@@ -409,6 +410,16 @@
<td>
@item.PosiQuantity.OtcFormat(OtcFormatFlag.StockEqvNotional)
</td>
<td>
@if (item.PosiFeeType == 0)
{
@(item.PosiTradingFeeUnit.ToString("0.0000") + "%")
}
else
{
@item.PosiTradingFeeUnit.ToString("0.00")
}
</td>
<td>
@item.PosiTradingFeePending.OtcFormat(OtcFormatFlag.StockEqvNotional)
</td>
+92
View File
@@ -0,0 +1,92 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
function createNumberFormat(precision) {
const formatter = (value) => Number(Number(value || 0).toFixed(precision));
formatter.precision = precision;
return formatter;
}
function loadUnwindHelpers() {
const filePath = path.join(__dirname, '../wwwroot/Scripts/app/swaptrade/unwindSwapTrade.js');
const code = fs.readFileSync(filePath, 'utf8') + '\nmodule.exports = { swapPosiFeeCalc, consPosiFeeType };';
const stockEqvNotional = createNumberFormat(2);
const sandbox = {
module: { exports: {} },
exports: {},
console,
require,
window: { otcformat: { options: {} } },
otcformat: {
options: {},
trading: {
premiumRateP: { precision: 4 },
tradePrice: { precision: 4 },
notional: { precision: 6 },
StockEqvNotional: stockEqvNotional,
marginRateP: { precision: 4 },
umpriceP: { precision: 4 }
},
fixed6: createNumberFormat(6)
},
model: {
ValueDate: '2026-07-27',
FlowEvents: [],
StructureType: '',
TradeStartDate: ''
},
isUseApproval: false,
Vue: function (options) { return options; },
FastVue: {
vueDatePicker() { return {}; },
vueNumberInput() { return {}; }
},
tradeHelper: { IsBond() { return false; } },
main: {
post() {
return {
done() { return this; }
};
},
message() { }
},
SwapCalc: {
roundHalfAwayFromZero(value) { return value; },
calcCloseQtyByOriginalPercent() { return 0; }
},
_: {
round(value, precision) {
return Number(Number(value || 0).toFixed(precision || 0));
}
}
};
sandbox.window.otcformat = sandbox.otcformat;
vm.runInNewContext(code, sandbox, { filename: filePath });
return sandbox.module.exports;
}
function expectClose(actual, expected, tolerance) {
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance || 1e-6);
}
describe('unwindSwapTrade 基础费率计算', () => {
const { swapPosiFeeCalc, consPosiFeeType } = loadUnwindHelpers();
test('百分比模式按平仓名义本金计算并保留两位', () => {
const result = swapPosiFeeCalc.calcTradingFee(consPosiFeeType.Percent, 0.1234, 1000000, 5000);
expectClose(result, 1234.00);
});
test('单位数量模式按平仓数量计算并保留两位', () => {
const result = swapPosiFeeCalc.calcTradingFee(consPosiFeeType.Unit, 1.235, 1000000, 10);
expectClose(result, 12.35);
});
test('未知模式默认按百分比模式处理', () => {
const result = swapPosiFeeCalc.calcTradingFee(99, 0.1, 200000, 10);
expectClose(result, 200.00);
});
});
@@ -12,11 +12,39 @@ const inputFormatTradeAmount = Object.freeze({ precision: otcformat.trading.noti
const inputFormatSwapRate = Object.freeze({ precision: otcformat.trading.premiumRateP.precision, negative: true, append: '%' });
const inputFormatTradePrice = Object.freeze({ precision: otcformat.trading.tradePrice.precision, negative: true, append: '' });
const inputFormatTradeSinglePrice = Object.freeze({ precision: otcformat.trading.tradeSinglePrice.precision, negative: true, append: '', percent: false });
const inputFormatPosiFeePercent = Object.freeze({ precision: 4, negative: true, append: '' });
const inputFormatPosiFeeUnit = Object.freeze({ precision: 2, negative: true, append: '' });
const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' });
const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, negative: true, append: '', percent: false });
const inputFormatSwapBondDeliveryPrice = Object.freeze({ precision: 9, negative: true, append: '', percent: true });
const inputFormatSwapBondNetPriceAndYtm = Object.freeze({ precision: 9, negative: true, append: '', percent: true });
const swapBondStoragePricePrecision = inputFormatSwapBondDeliveryPrice.precision + 2;
const consPosiFeeType = Object.freeze({ Percent: 0, Unit: 1 });
const swapPosiFeeCalc = Object.freeze({
normalizeFeeType(feeType) {
return Number(feeType) === consPosiFeeType.Unit ? consPosiFeeType.Unit : consPosiFeeType.Percent;
},
calcPending(feeType, feeUnit, stockEqvNotional, quantity) {
const normalizedFeeType = this.normalizeFeeType(feeType);
const normalizedFeeUnit = Number(feeUnit) || 0;
const normalizedNotional = Number(stockEqvNotional) || 0;
const normalizedQuantity = Number(quantity) || 0;
const tradingFeePending = normalizedFeeType === consPosiFeeType.Percent
? normalizedFeeUnit / 100 * normalizedNotional
: normalizedFeeUnit * normalizedQuantity;
return otcformat.trading.tradeSinglePrice(tradingFeePending);
},
calcFeeUnit(feeType, tradingFeePending, stockEqvNotional, quantity) {
const normalizedFeeType = this.normalizeFeeType(feeType);
const normalizedTradingFeePending = Number(tradingFeePending) || 0;
const normalizedNotional = Number(stockEqvNotional) || 0;
const normalizedQuantity = Number(quantity) || 0;
if (normalizedFeeType === consPosiFeeType.Percent) {
return normalizedNotional === 0 ? 0 : _.round(normalizedTradingFeePending / normalizedNotional * 100, inputFormatPosiFeePercent.precision);
}
return normalizedQuantity === 0 ? 0 : _.round(normalizedTradingFeePending / normalizedQuantity, inputFormatPosiFeeUnit.precision);
}
});
const consUnderlyingFlagBase = (function () {
let unSelFlag = tradeHelper.UnderlyingSelectFlag;
@@ -153,6 +181,7 @@ const vue = new Vue({
currencys: page.currencys,
getNotionalSingleFee: 0,
isSingleFee: page.Trade.trade_extend.ExtendObj.OpenFeeType == 0,
posiFeeModePercent: true,
observation: {//互换观察日
ObservationInterval: "",
IntervalList: [],
@@ -236,6 +265,41 @@ const vue = new Vue({
const isBond = tradeHelper.IsBond(item && item.UnderlyingInstrumentType);
return `${index}-${field}-${isBond ? 'bond' : 'other'}`;
},
getCurrentPosiFeeType() {
return this.posiFeeModePercent ? consPosiFeeType.Percent : consPosiFeeType.Unit;
},
normalizePosiFeeType(feeType) {
return swapPosiFeeCalc.normalizeFeeType(feeType);
},
syncPosiFeeModeByItem(item) {
this.posiFeeModePercent = this.normalizePosiFeeType(item && item.PosiFeeType) !== consPosiFeeType.Unit;
},
syncPayItemFeeType(item) {
item.PosiFeeType = this.getCurrentPosiFeeType();
},
refreshTradingFeePendingByUnit(item) {
this.syncPayItemFeeType(item);
item.PosiTradingFeePending = swapPosiFeeCalc.calcPending(
item.PosiFeeType,
item.PosiTradingFeeUnit,
this.trade.StockEqvNotional,
item.PosiQuantity
);
},
refreshTradingFeeUnitByPending(item) {
this.syncPayItemFeeType(item);
item.PosiTradingFeeUnit = swapPosiFeeCalc.calcFeeUnit(
item.PosiFeeType,
item.PosiTradingFeePending,
this.trade.StockEqvNotional,
item.PosiQuantity
);
},
refreshPayTradingFeesByUnit() {
this.paySwapList.forEach(item => {
this.refreshTradingFeePendingByUnit(item);
});
},
changeStructureType() {
this.trade.StockEqvNotional = 0;
let direction = this.trade.trade_extend.ExtendObj.Direction;
@@ -385,6 +449,7 @@ const vue = new Vue({
//变更名义本金(仅格式化,不反算数量)
changeStockEqvNotional() {
this.trade.StockEqvNotional = otcformat.trading.StockEqvNotional(this.trade.StockEqvNotional);
this.refreshPayTradingFeesByUnit();
//计算数量
// if (this.paySwapList.length > 0) {
// var item = this.paySwapList[0];
@@ -474,6 +539,7 @@ const vue = new Vue({
var stockEqvNotional = SwapCalc.calcStockEqvNotional(deliveryPrice, national);//名义本金=期初价格*数量*乘数
this.trade.StockEqvNotional = otcformat.trading.stockEqvNotional(stockEqvNotional);
payItem.PosiNotionalValue = this.trade.StockEqvNotional;
this.refreshPayTradingFeesByUnit();
}
},
//变更到期日
@@ -509,26 +575,27 @@ const vue = new Vue({
},
//变更单位交易费用
changeTradingFeeUnit(item) {
//计算交易费用
//if (this.trade.trade_extend.ExtendObj.OpenFeeType == 0) {//按手数收费
// item.PosiTradingFee = item.ContractSize == 0 ? 0 : otcformat.trading.tradeSinglePrice(item.PosiQuantity * item.PosiTradingFeeUnit / item.ContractSize);
//} else {
// item.PosiTradingFee = otcformat.trading.tradeSinglePrice(item.PosiQuantity * item.PosiTradingFeeUnit);
//}
this.refreshTradingFeePendingByUnit(item);
},
//变更交易费用
changeTradingFee(item) {
//计算单位交易费用
//if (item.PosiQuantity == 0) {
// item.PosiTradingFeeUnit = 0;
// return
//}
//if (this.trade.trade_extend.ExtendObj.OpenFeeType == 0) {//按手数收费
// item.PosiTradingFeeUnit = item.ContractSize == 0 ? 0 : otcformat.trading.tradeSinglePrice(item.PosiTradingFee * item.ContractSize / item.PosiQuantity);
//} else {
// item.PosiTradingFeeUnit = otcformat.trading.tradeSinglePrice(item.PosiTradingFee / item.PosiQuantity);
//}
this.refreshTradingFeeUnitByPending(item);
},
showPayAbsPrice() {
this.posiFeeModePercent = false;
this.paySwapList.forEach(item => {
item.PosiFeeType = consPosiFeeType.Unit;
item.PosiTradingFeeUnit = 0;
item.PosiTradingFeePending = 0;
});
},
showPayPercentPrice() {
this.posiFeeModePercent = true;
this.paySwapList.forEach(item => {
item.PosiFeeType = consPosiFeeType.Percent;
item.PosiTradingFeeUnit = 0;
item.PosiTradingFeePending = 0;
});
},
savetrade() {
if (!this.checkSubmitData()) {
@@ -618,6 +685,7 @@ const vue = new Vue({
x.PosiGrossPrice = thisObj.roundStorageDeliveryPrice(x, x.PosiGrossPrice);
x.PosiNetNoFeePrice = thisObj.roundStorageBondNetPriceAndYtm(x.PosiNetNoFeePrice);
x.InitYtm = x.InitYtm == null ? null : thisObj.roundStorageBondNetPriceAndYtm(x.InitYtm);
x.PosiFeeType = thisObj.normalizePosiFeeType(x.PosiFeeType);
thisObj.trade.swap_positions.push(x);
});
} else {
@@ -1439,6 +1507,7 @@ const vue = new Vue({
thisObj.paySwapList = thisObj.trade.swap_positions.filter(x => { if (x.UnderlyingCode != null && x.UnderlyingCode.length != 0 && x.IsInitial) return x; });
thisObj.paySwapList.forEach((val, num, arr) => {
arr[num].index = num;
arr[num].PosiFeeType = thisObj.normalizePosiFeeType(arr[num].PosiFeeType);
this.StockEqvNotional = val.ContractSize * val.PosiQuantity * val.PosiGrossPrice;
// D2 修复:重开(审批重开/刷新)已保存的债券成交单时,三字段互算的手动标志随页面重置而丢失;
// 若不锁,用户一旦编辑任一价格字段就会以它为源重新反算、覆盖当初保存的其他两格。
@@ -1450,6 +1519,9 @@ const vue = new Vue({
thisObj.$set(arr[num], 'bondManual', { CP: true, DP: true, YD: true });
}
});
if (thisObj.paySwapList.length > 0) {
thisObj.syncPosiFeeModeByItem(thisObj.paySwapList[0]);
}
}
if (thisObj.paySwapList.length == 0) {
@@ -1576,6 +1648,7 @@ const vue = new Vue({
PosiTradingFee: 0,//交易费用
PosiTradingFeePending: 0,//交易费用后付
PosiTradingFeeUnit: 0,//单位交易费用
PosiFeeType: thisObj.getCurrentPosiFeeType(),//单位交易费用模式
InterestDirection: 0,//利息收支方式
InterestRateDefault: 0,//计息利率
InterestMode: 0,//计息基本类型
@@ -8,6 +8,22 @@ const inputFormatEqvNotional = Object.freeze({ precision: otcformat.trading.Stoc
const inputFormatMarginRate = Object.freeze({ precision: otcformat.trading.marginRateP.precision, append: '%' });
const inputFormatMarginRateNoPercent = Object.freeze({ precision: otcformat.trading.umpriceP.precision, append: '', percent: true });
const inputFormatSwapDeliveryPrice = Object.freeze({ precision: 9, append: '', negative: true });
const consPosiFeeType = Object.freeze({ Percent: 0, Unit: 1 });
const swapPosiFeeCalc = {
normalizeFeeType(feeType) {
return Number(feeType) === consPosiFeeType.Unit ? consPosiFeeType.Unit : consPosiFeeType.Percent;
},
calcTradingFee(feeType, feeUnit, closeNotionalValue, closeQty) {
const normalizedFeeType = this.normalizeFeeType(feeType);
const normalizedFeeUnit = Number(feeUnit) || 0;
const normalizedCloseNotionalValue = Number(closeNotionalValue) || 0;
const normalizedCloseQty = Number(closeQty) || 0;
const tradingFee = normalizedFeeType === consPosiFeeType.Unit
? normalizedFeeUnit * normalizedCloseQty
: normalizedFeeUnit / 100 * normalizedCloseNotionalValue;
return otcformat.trading.StockEqvNotional(_.round(tradingFee, 2));
}
};
let ValueDate = model.ValueDate;
const vue = new Vue({
el: '#vueDiv',
@@ -140,6 +156,7 @@ const vue = new Vue({
this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent);
}
this.calcTradingFeePending();
this.refreshTradingFeeByUnit();
this.getInterestList();
this.calcFloatClosePnl();
},
@@ -153,6 +170,13 @@ const vue = new Vue({
calcTradingFeePending() {
this.floatPosition.TradingFeePending = this.floatPosition.BeforeCloseFee * parseFloat(this.deal.ClosePercent);
},
refreshTradingFeeByUnit() {
this.floatPosition.TradingFee = swapPosiFeeCalc.calcTradingFee(
this.floatPosition.PosiFeeType,
this.floatPosition.PosiTradingFeeUnit,
this.deal.CloseNotionalValue,
this.deal.CloseQty);
},
changeCloseQty() {//修改平仓数量
if (parseFloat(this.deal.CloseQty) > parseFloat(this.deal.PositionQty)) {
main.message("平仓数量不能超过持仓数量");
@@ -169,6 +193,7 @@ const vue = new Vue({
// 占期初口径:平仓名义本金 = 平仓比例 × 期初名义本金(NotionalValue)
this.deal.CloseNotionalValue = otcformat.trading.StockEqvNotional(parseFloat(this.deal.ClosePercent) * parseFloat(this.deal.NotionalValue));
this.calcTradingFeePending();
this.refreshTradingFeeByUnit();
this.getInterestList();
this.calcFloatClosePnl();
},
@@ -187,6 +212,7 @@ const vue = new Vue({
this.deal.CloseMethod = 2;
}
this.calcTradingFeePending();
this.refreshTradingFeeByUnit();
this.getInterestList();
this.calcFloatClosePnl();
},
@@ -205,6 +231,7 @@ const vue = new Vue({
this.deal.CloseMethod = 2;
}
this.calcTradingFeePending();
this.refreshTradingFeeByUnit();
this.getInterestList();
this.calcFloatClosePnl();
},