From 415539704ddb43f54de34e6e4e089fe3192a0b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=90=8D=E9=94=90?= <1565842059@qq.com> Date: Fri, 17 Jul 2026 10:58:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(swap):=20=E4=BF=AE=E5=A4=8D=E5=80=BA?= =?UTF-8?q?=E5=88=B8=E7=BB=93=E6=81=AF=E4=BB=B7=E5=B7=AE=E7=9B=88=E4=BA=8F?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将价差盈亏计算从使用CloseNotionalValue改为使用PositionQty和ContractSize - 更新前端JavaScript代码中的计算公式,按持仓数量和合约乘数计算价差盈亏 - 在UnwindInput模型中添加PositionQty和ContractSize字段 - 修正后台计算服务中的数据映射逻辑 - 添加FC_009测试用例验证债券价差按数量计算的正确性 - 更新现有测试用例的输入参数以匹配新的计算方式 --- .../FrontendCalcCharacterizationTest.cs | 45 ++++++++++++++++--- YLErpDAL/Helpers/FrontendCalcReference.cs | 10 +++-- .../Modules/SwapModule/SwapDealService.cs | 4 +- YLErpWeb/fe-tests/swapCalc.test.js | 22 +++++++-- .../Scripts/app/swaptrade/incomeSwapTrade.js | 6 ++- .../wwwroot/Scripts/app/swaptrade/swapCalc.js | 4 +- 6 files changed, 74 insertions(+), 17 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/FrontendCalcCharacterizationTest.cs b/UnitTestProject/Modules/SwapModule/FrontendCalcCharacterizationTest.cs index 233b547a..59b7856f 100644 --- a/UnitTestProject/Modules/SwapModule/FrontendCalcCharacterizationTest.cs +++ b/UnitTestProject/Modules/SwapModule/FrontendCalcCharacterizationTest.cs @@ -156,9 +156,9 @@ namespace YLErp.Modules.SwapModule /// /// [FC_006] 结息-债券多头-全量结算(基线) - /// income 用 CloseNotionalValue 而非 CloseQty,无 longRatio - /// EntryPrice=1.02, TradingAmountAvg=105(×100形态), CloseNotionalValue=10000 - /// MarkClosePnl = 10000×(105×0.01−1.02)×1 = 10000×0.03 = 300 + /// income 使用持仓数量和合约乘数,无 longRatio + /// EntryPrice=1.02, TradingAmountAvg=105(×100形态), PositionQty=10000, ContractSize=1 + /// MarkClosePnl = 10000×1×(105×0.01−1.02)×1 = 10000×0.03 = 300 /// [TestMethod] public void FC_006_结息_债券多头_全量结算() @@ -166,7 +166,9 @@ namespace YLErp.Modules.SwapModule var input = new UnwindInput { Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m, - CloseNotionalValue = 10000, // income 用名义本金 + PositionQty = 10000, + ContractSize = 1, + CloseNotionalValue = 10200, // 与数量刻意不同,守卫 income 不再误用名义本金 CloseQty = 0, // income 不用数量 PayDirection = 1, PositionType = 1, TradingFee = "0", TradingFeePending = "0", DividendIn = "0" @@ -188,7 +190,8 @@ namespace YLErp.Modules.SwapModule var input = new UnwindInput { Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 110m, - CloseNotionalValue = 10000, CloseQty = 0, + PositionQty = 10000, ContractSize = 1, + CloseNotionalValue = 10200, CloseQty = 0, PayDirection = 1, PositionType = 1, TradingFee = "0", TradingFeePending = "0", DividendIn = "0" }; @@ -209,7 +212,8 @@ namespace YLErp.Modules.SwapModule var input = new UnwindInput { Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m, - CloseNotionalValue = 10000, CloseQty = 0, + PositionQty = 10000, ContractSize = 1, + CloseNotionalValue = 10200, CloseQty = 0, PayDirection = 1, PositionType = 1, TradingFee = "0", TradingFeePending = "0", DividendIn = "0" }; @@ -225,6 +229,35 @@ namespace YLErp.Modules.SwapModule Console.WriteLine($"FC_008: SwapRealizedPnL={result.SwapRealizedPnL}, SwapMarginRebatePnl={result.SwapMarginRebatePnl} ✅"); } + /// + /// [FC_009] 结息-债券支付端:价差盈亏必须按数量计算,不能按期初名义本金计算。 + /// 纯价差 = 30000000×1×(80%−98%)×(−1) = 5400000;加分红-45000后合计5355000。 + /// + [TestMethod] + public void FC_009_结息_债券价差按数量计算() + { + var input = new UnwindInput + { + Multiplier = 100, + PosiGrossPrice = 0.98m, + TradingAmountAvg = 80m, + PositionQty = 30000000m, + ContractSize = 1m, + CloseNotionalValue = 29400000m, + CloseQty = 0m, + PayDirection = 2, + PositionType = 1, + TradingFee = "0", + TradingFeePending = "0", + DividendIn = "-45000" + }; + + var result = FrontendCalcReference.CalcIncome(input); + + AssertDecimalEqual(5400000m, result.MarkClosePnl, 0.01m, "income MarkClosePnl按数量计算"); + AssertDecimalEqual(5355000m, result.FloatPnlSum, 0.01m, "income FloatPnlSum包含分红"); + } + private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "") { Assert.IsTrue(Math.Abs(expected - actual) <= tolerance, diff --git a/YLErpDAL/Helpers/FrontendCalcReference.cs b/YLErpDAL/Helpers/FrontendCalcReference.cs index cd2585e8..7f9880af 100644 --- a/YLErpDAL/Helpers/FrontendCalcReference.cs +++ b/YLErpDAL/Helpers/FrontendCalcReference.cs @@ -89,7 +89,7 @@ namespace YLErp.Helpers /// /// 计算结息页(income)的盯市盈亏与汇总。 /// 对应 incomeSwapTrade.js:128-178。 - /// 差异:用 CloseNotionalValue(非 CloseQty)作量纲,无 longRatio,无 Math.round/10000。 + /// 差异:用剩余持仓数量和合约乘数作量纲,无 longRatio,无 Math.round/10000。 /// public static UnwindResult CalcIncome(UnwindInput input) { @@ -102,9 +102,9 @@ namespace YLErp.Helpers decimal tradingFeePending = ParseOrZero(input.TradingFeePending); decimal dividendIn = ParseOrZero(input.DividendIn); - // MarkClosePnl = CloseNotionalValue × (TradingAmountAvg × scale − EntryPrice) × floatRatio + // MarkClosePnl = PositionQty × ContractSize × (TradingAmountAvg × scale − EntryPrice) × floatRatio // (无 longRatio、无 Math.round/10000) - decimal markClosePnl = input.CloseNotionalValue * (input.TradingAmountAvg * scale - entryPrice) * floatRatio; + decimal markClosePnl = input.PositionQty * input.ContractSize * (input.TradingAmountAvg * scale - entryPrice) * floatRatio; markClosePnl = StockEqvNotional(markClosePnl); decimal floatPnlSum = decimal.Parse( @@ -156,7 +156,9 @@ namespace YLErp.Helpers public decimal PosiGrossPrice; // EntryDirtyPrice(期初全价不含费) public decimal TradingAmountAvg; // 用户可改的期末标的价格(界面×multiplier形态) public decimal CloseQty; // 平仓数量 - public decimal CloseNotionalValue;// 平仓名义本金(income 用) + public decimal PositionQty; // 结息时的剩余持仓数量 + public decimal ContractSize = 1m; // 合约乘数 + public decimal CloseNotionalValue;// 平仓名义本金 public int PayDirection; // 1=收取,-1=支付 public int PositionType; // 1=多头,2=空头 public string TradingFee; // 交易费用(前端是字符串) diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 75f98fbd..bff18695 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -163,6 +163,8 @@ namespace YLErp.Modules.SwapModule PosiGrossPrice = floatLeg.PosiGrossPrice, // EntryDirtyPrice TradingAmountAvg = floatLeg.TradingAmountAvg, // ExitDirtyPrice(界面×multiplier形态) CloseQty = unwindData.CloseQty, + PositionQty = unwindData.PositionQty, + ContractSize = floatLeg.ContractSize, CloseNotionalValue = unwindData.CloseNotionalValue, PayDirection = floatLeg.PayDirection, PositionType = floatLeg.PositionType, @@ -445,7 +447,7 @@ namespace YLErp.Modules.SwapModule unwindData.NotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0); unwindData.NotionalQty = positions.Where(x => x.IsInitial).Sum(s => s.PosiQuantity); unwindData.PosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); - unwindData.PositionQty = Convert.ToDecimal(td.TradeAmount); + unwindData.PositionQty = position != null ? position.PosiQuantity : Convert.ToDecimal(td.TradeAmount); unwindData.AnnualDays = tradeExtend == null ? 365 : tradeExtend.ExtendObj.AnnualDays; unwindData.ClosePercent = unwindData.PosiNotionalValue / unwindData.NotionalValue; unwindData.CloseNotionalValue = unwindData.PosiNotionalValue; diff --git a/YLErpWeb/fe-tests/swapCalc.test.js b/YLErpWeb/fe-tests/swapCalc.test.js index 435bc4a8..25ff3deb 100644 --- a/YLErpWeb/fe-tests/swapCalc.test.js +++ b/YLErpWeb/fe-tests/swapCalc.test.js @@ -222,7 +222,8 @@ describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', test('FC_006 结息 债券多头 全量', () => { const r = SwapCalc.calcIncome({ multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, - closeNotionalValue: 10000, closeQty: 0, payDirection: 1, positionType: 1, + positionQty: 10000, contractSize: 1, + closeNotionalValue: 10200, closeQty: 0, payDirection: 1, positionType: 1, tradingFee: '0', tradingFeePending: '0', dividendIn: '0' }); expectClose(r.MarkClosePnl, 300, 'income MarkClosePnl'); @@ -233,7 +234,8 @@ describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', test('FC_007 结息 改标的价格', () => { const r = SwapCalc.calcIncome({ multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 110, - closeNotionalValue: 10000, closeQty: 0, payDirection: 1, positionType: 1, + positionQty: 10000, contractSize: 1, + closeNotionalValue: 10200, closeQty: 0, payDirection: 1, positionType: 1, tradingFee: '0', tradingFeePending: '0', dividendIn: '0' }); expectClose(r.MarkClosePnl, 800, '改价格后 income MarkClosePnl'); @@ -243,7 +245,8 @@ describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', test('FC_008 结息 含利息腿与预付金腿 总额', () => { const r = SwapCalc.calcIncome({ multiplier: 100, posiGrossPrice: 1.02, tradingAmountAvg: 105, - closeNotionalValue: 10000, closeQty: 0, payDirection: 1, positionType: 1, + positionQty: 10000, contractSize: 1, + closeNotionalValue: 10200, closeQty: 0, payDirection: 1, positionType: 1, tradingFee: '0', tradingFeePending: '0', dividendIn: '0', interestLegs: [{ interestClosePnL: 100 }], marginLegs: [{ interestClosePnL: 50 }] @@ -251,4 +254,17 @@ describe('交叉校验:对齐 C# FrontendCalcCharacterizationTest 金标准', expectClose(r.SwapRealizedPnL, 450, '含利息+预付金 SwapRealizedPnL'); expectClose(r.SwapMarginRebatePnl, 50, 'SwapMarginRebatePnl'); }); + + // FC_009 真实回归:债券结息价差按数量计算,不能误用期初名义本金 + test('FC_009 结息 债券价差按数量计算', () => { + const r = SwapCalc.calcIncome({ + multiplier: 100, posiGrossPrice: 0.98, tradingAmountAvg: 80, + positionQty: 30000000, contractSize: 1, + closeNotionalValue: 29400000, closeQty: 0, + payDirection: 2, positionType: 1, + tradingFee: '0', tradingFeePending: '0', dividendIn: '-45000' + }); + expectClose(r.MarkClosePnl, 5400000, 'income MarkClosePnl按数量计算'); + expectClose(r.FloatPnlSum, 5355000, 'income FloatPnlSum包含分红'); + }); }); diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js index 568f2ecd..7afe1c9a 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/incomeSwapTrade.js @@ -177,8 +177,10 @@ const vue = new Vue({ let TradingFeePending = thisObj.floatPosition.TradingFeePending == "" ? 0 : parseFloat(thisObj.floatPosition.TradingFeePending); let DividendIn = thisObj.floatPosition.DividendIn == "" ? 0 : parseFloat(thisObj.floatPosition.DividendIn ?? 0); let scale = thisObj.getPriceScale(); - //thisObj.floatPosition.MarkClosePnl = thisObj.deal.CloseNotionalValue * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiNetPrice) * floatRatio; - thisObj.floatPosition.MarkClosePnl = thisObj.deal.CloseNotionalValue * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiGrossPrice) * floatRatio; + // 债券全价是单位价格,价差盈亏应按持仓数量×合约乘数计算; + // CloseNotionalValue 是期初全价折算后的名义本金,直接乘价差会重复包含期初价格。 + let positionAmount = parseFloat(thisObj.floatPosition.Quantity) * parseFloat(thisObj.floatPosition.ContractSize || 1); + thisObj.floatPosition.MarkClosePnl = positionAmount * (thisObj.floatPosition.TradingAmountAvg * scale - thisObj.initPosiGrossPrice) * floatRatio; thisObj.floatPosition.MarkClosePnl = otcformat.trading.StockEqvNotional(thisObj.floatPosition.MarkClosePnl);//MarkClosePnl 纯盯市不要计算交易费用和分红 // 守卫: 浮动盈亏合计必须保留 2 位小数 → 对应历史 bug 3c5f25a5(原代码缺精度保留) // 数值由 swapCalc.calcFloatPnlSum 计算, 此处 .toFixed(2) 仅保留字符串类型以兼容下游 diff --git a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js index 65a47daa..4e1c39c3 100644 --- a/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js +++ b/YLErpWeb/wwwroot/Scripts/app/swaptrade/swapCalc.js @@ -167,8 +167,10 @@ var tradingFeePending = parseOrZero(input.tradingFeePending); var dividendIn = parseOrZero(input.dividendIn); + var contractSize = input.contractSize === undefined || input.contractSize === null + ? 1 : Number(input.contractSize); var markClosePnl = roundHalfAwayFromZero( - input.closeNotionalValue * (input.tradingAmountAvg * scale - entryPrice) * floatRatio, 2); + input.positionQty * contractSize * (input.tradingAmountAvg * scale - entryPrice) * floatRatio, 2); var floatPnlSum = roundHalfAwayFromZero(markClosePnl + tradingFee + tradingFeePending + dividendIn, 2);