fix(swap): 修复债券结息价差盈亏计算逻辑
- 将价差盈亏计算从使用CloseNotionalValue改为使用PositionQty和ContractSize - 更新前端JavaScript代码中的计算公式,按持仓数量和合约乘数计算价差盈亏 - 在UnwindInput模型中添加PositionQty和ContractSize字段 - 修正后台计算服务中的数据映射逻辑 - 添加FC_009测试用例验证债券价差按数量计算的正确性 - 更新现有测试用例的输入参数以匹配新的计算方式
This commit is contained in:
@@ -156,9 +156,9 @@ namespace YLErp.Modules.SwapModule
|
||||
|
||||
/// <summary>
|
||||
/// [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
|
||||
/// </summary>
|
||||
[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} ✅");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [FC_009] 结息-债券支付端:价差盈亏必须按数量计算,不能按期初名义本金计算。
|
||||
/// 纯价差 = 30000000×1×(80%−98%)×(−1) = 5400000;加分红-45000后合计5355000。
|
||||
/// </summary>
|
||||
[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,
|
||||
|
||||
@@ -89,7 +89,7 @@ namespace YLErp.Helpers
|
||||
/// <summary>
|
||||
/// 计算结息页(income)的盯市盈亏与汇总。
|
||||
/// 对应 incomeSwapTrade.js:128-178。
|
||||
/// 差异:用 CloseNotionalValue(非 CloseQty)作量纲,无 longRatio,无 Math.round/10000。
|
||||
/// 差异:用剩余持仓数量和合约乘数作量纲,无 longRatio,无 Math.round/10000。
|
||||
/// </summary>
|
||||
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; // 交易费用(前端是字符串)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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包含分红');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) 仅保留字符串类型以兼容下游
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user