diff --git a/UnitTestProject/Modules/SwapModule/FrontendCalcCharacterizationTest.cs b/UnitTestProject/Modules/SwapModule/FrontendCalcCharacterizationTest.cs new file mode 100644 index 00000000..a4fec34f --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/FrontendCalcCharacterizationTest.cs @@ -0,0 +1,403 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 前端计算逻辑特征化测试(Characterization Test) + /// ============================================================================ + /// 目的:用 golden 冻结前端 JS 的计算行为(含用户可变输入分支), + /// 作为下一轮"计算下沉后端"的金标准——后端结果必须匹配这些 golden。 + /// + /// 背景:前端 unwindSwapTrade.js / incomeSwapTrade.js 是实时响应式计算器, + /// 用户改标的价格/平仓数量/交易费用/利息金额时,前端立刻重算 MarkClosePnl/ + /// SwapRealizedPnL/SwapCloseAmount,后端拿到"前端算好的最终结果"直接记账。 + /// 本测试用 C# 忠实重写前端公式作参考实现,手算真实输入的期望值存 golden。 + /// + /// 命名规范(见命名决策文档):参考实现内部用规范名(EntryPrice/ExitPrice/ + /// floatRatio/longRatio),注释标明对应前端字段与规范语义。 + /// ============================================================================ + [TestClass] + public class FrontendCalcCharacterizationTest + { + private static readonly string GoldenDir = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "FrontendCalc"); + + // ================================================================ + // FrontendCalcReference:前端公式的 C# 忠实重写(参考实现,非生产代码) + // ================================================================ + + /// + /// 前端公式参考实现。输入=前端可见的原始字段,输出=前端算出的衍生字段。 + /// 注意:otcformat.trading.StockEqvNotional 的精度按 MoneyRound=2 模拟。 + /// + private static class FrontendCalcReference + { + /// 模拟 otcformat.trading.StockEqvNotional(金额类,2位小数) + private static decimal StockEqvNotional(decimal v) => Math.Round(v, 2, MidpointRounding.AwayFromZero); + + /// + /// 计算平仓页(unwind)的盯市盈亏与汇总。 + /// 对应 unwindSwapTrade.js:196-261(calcFloatClosePnl + calcCloseAmount)。 + /// + public static UnwindResult CalcUnwind(UnwindInput input) + { + // 规范名映射:initPosiNetPrice(前端变量) = PosiGrossPrice = EntryDirtyPrice(期初全价不含费) + decimal entryPrice = input.PosiGrossPrice; + // scale = getPriceScale():债券(multiplier=100)→0.01,非债券→1 + decimal scale = input.Multiplier == 100 ? 0.01m : 1m; + // 方向因子:PayDirection(1=收取)→+1;PositionType(1=多头)→+1 + decimal floatRatio = input.PayDirection == 1 ? 1 : -1; + decimal longRatio = input.PositionType == 1 ? 1 : -1; + + decimal tradingFee = ParseOrZero(input.TradingFee); + decimal tradingFeePending = ParseOrZero(input.TradingFeePending); + decimal dividendIn = ParseOrZero(input.DividendIn); + + // MarkClosePnl = round(CloseQty × (TradingAmountAvg × scale − EntryPrice) × floatRatio × longRatio × 10000)/10000 + decimal markClosePnl = Math.Round( + input.CloseQty * (input.TradingAmountAvg * scale - entryPrice) * floatRatio * longRatio * 10000) / 10000; + // toFixed(2) → StockEqvNotional + markClosePnl = Math.Round(markClosePnl, 2, MidpointRounding.AwayFromZero); + markClosePnl = StockEqvNotional(markClosePnl); + + // FloatPnlSum = (MarkClosePnl + TradingFee + TradingFeePending + DividendIn).toFixed(2) + decimal floatPnlSum = decimal.Parse( + (markClosePnl + tradingFee + tradingFeePending + dividendIn).ToString("F2")); + + // calcCloseAmount:SwapRealizedPnL/SwapCloseAmount = FloatPnlSum + Σ利息腿 + Σ预付金腿 + decimal swapCloseAmount = floatPnlSum; + decimal swapRealizedPnL = floatPnlSum; + decimal swapMarginRebatePnl = 0m; + foreach (var interest in input.InterestLegs) + { + swapCloseAmount += interest.InterestClosePnL; + swapRealizedPnL += interest.InterestClosePnL; + } + foreach (var margin in input.MarginLegs) + { + swapCloseAmount += margin.InterestClosePnL; + swapMarginRebatePnl += margin.InterestClosePnL; + swapRealizedPnL += margin.InterestClosePnL; + } + swapRealizedPnL = StockEqvNotional(swapRealizedPnL); + swapCloseAmount = StockEqvNotional(swapCloseAmount); + swapMarginRebatePnl = StockEqvNotional(swapMarginRebatePnl); + + // TradingAmountFeeAvg = CloseQty==0 ? 0 : (TradingAmountAvg×scale + TradingFee/CloseQty × ratio) + // 注:unwind 的 ratio = PositionType?1:-1(calcCloseAmount 内重新定义) + decimal ratio = input.PositionType == 1 ? 1 : -1; + decimal tradingAmountFeeAvg = input.CloseQty == 0 ? 0 : + input.TradingAmountAvg * scale + (tradingFee / input.CloseQty) * ratio; + + return new UnwindResult + { + MarkClosePnl = markClosePnl, + FloatPnlSum = floatPnlSum, + SwapRealizedPnL = swapRealizedPnL, + SwapCloseAmount = swapCloseAmount, + SwapMarginRebatePnl = swapMarginRebatePnl, + TradingAmountFeeAvg = tradingAmountFeeAvg + }; + } + + /// + /// 计算结息页(income)的盯市盈亏与汇总。 + /// 对应 incomeSwapTrade.js:128-178。 + /// 差异:用 CloseNotionalValue(非 CloseQty)作量纲,无 longRatio,无 Math.round/10000。 + /// + public static UnwindResult CalcIncome(UnwindInput input) + { + // income 页 initPosiGrossPrice = PosiGrossPrice = EntryDirtyPrice + decimal entryPrice = input.PosiGrossPrice; + decimal scale = input.Multiplier == 100 ? 0.01m : 1m; + decimal floatRatio = input.PayDirection == 1 ? 1 : -1; + + decimal tradingFee = ParseOrZero(input.TradingFee); + decimal tradingFeePending = ParseOrZero(input.TradingFeePending); + decimal dividendIn = ParseOrZero(input.DividendIn); + + // MarkClosePnl = CloseNotionalValue × (TradingAmountAvg × scale − EntryPrice) × floatRatio + // (无 longRatio、无 Math.round/10000) + decimal markClosePnl = input.CloseNotionalValue * (input.TradingAmountAvg * scale - entryPrice) * floatRatio; + markClosePnl = StockEqvNotional(markClosePnl); + + decimal floatPnlSum = decimal.Parse( + (markClosePnl + tradingFee + tradingFeePending + dividendIn).ToString("F2")); + + decimal swapCloseAmount = floatPnlSum; + decimal swapRealizedPnL = floatPnlSum; + decimal swapMarginRebatePnl = 0m; + foreach (var interest in input.InterestLegs) + { + swapCloseAmount += interest.InterestClosePnL; + swapRealizedPnL += interest.InterestClosePnL; + } + foreach (var margin in input.MarginLegs) + { + swapCloseAmount += margin.InterestClosePnL; + swapMarginRebatePnl += margin.InterestClosePnL; + swapRealizedPnL += margin.InterestClosePnL; + } + // income 页无 SwapMarginAmount 计算(恒为0) + swapRealizedPnL = StockEqvNotional(swapRealizedPnL); + swapCloseAmount = StockEqvNotional(swapCloseAmount); + swapMarginRebatePnl = StockEqvNotional(swapMarginRebatePnl); + + // TradingAmountFeeAvg = CloseQty>0 ? (TradingAmountAvg×scale + TradingFee/CloseQty × floatRatio) : TradingAmountAvg×scale + // 注:income 用 floatRatio(PayDirection),与 unwind 的 ratio(PositionType) 不同 + decimal tradingAmountFeeAvg = input.CloseQty > 0 + ? input.TradingAmountAvg * scale + (tradingFee / input.CloseQty) * floatRatio + : input.TradingAmountAvg * scale; + + return new UnwindResult + { + MarkClosePnl = markClosePnl, + FloatPnlSum = floatPnlSum, + SwapRealizedPnL = swapRealizedPnL, + SwapCloseAmount = swapCloseAmount, + SwapMarginRebatePnl = swapMarginRebatePnl, + TradingAmountFeeAvg = tradingAmountFeeAvg + }; + } + + private static decimal ParseOrZero(string s) => string.IsNullOrEmpty(s) ? 0m : decimal.Parse(s); + } + + // 输入/输出模型 + private class UnwindInput + { + public int Multiplier; // 债券=100,非债券=1 + public decimal PosiGrossPrice; // EntryDirtyPrice(期初全价不含费) + public decimal TradingAmountAvg; // 用户可改的期末标的价格(界面×multiplier形态) + public decimal CloseQty; // 平仓数量 + public decimal CloseNotionalValue;// 平仓名义本金(income 用) + public int PayDirection; // 1=收取,-1=支付 + public int PositionType; // 1=多头,2=空头 + public string TradingFee; // 交易费用(前端是字符串) + public string TradingFeePending; // 待结算费用 + public string DividendIn; // 分红 + public List InterestLegs = new(); + public List MarginLegs = new(); + } + + private class LegInput + { + public decimal InterestClosePnL; // 利息腿平仓盈亏(已含方向) + } + + private class UnwindResult + { + public decimal MarkClosePnl; + public decimal FloatPnlSum; + public decimal SwapRealizedPnL; + public decimal SwapCloseAmount; + public decimal SwapMarginRebatePnl; + public decimal TradingAmountFeeAvg; + } + + // ================================================================ + // 8 个测试场景(含用户可变输入分支) + // ================================================================ + + // ---- 平仓页(unwind)场景 ---- + + /// + /// [FC_001] 平仓-债券多头-默认值(基线) + /// EntryDirtyPrice(PosiGrossPrice)=1.02, ExitPrice(TradingAmountAvg,×100形态)=105, + /// CloseQty=1000, PayDirection=1(收取), PositionType=1(多头), TradingFee="20" + /// scale=0.01, floatRatio=1, longRatio=1 + /// MarkClosePnl = round(1000×(105×0.01−1.02)×1×1×10000)/10000 = round(1000×0.03×10000)/10000 = 30 + /// + [TestMethod] + public void FC_001_平仓_债券多头_默认值() + { + var input = new UnwindInput + { + Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m, + CloseQty = 1000, PayDirection = 1, PositionType = 1, + TradingFee = "20", TradingFeePending = "0", DividendIn = "0" + }; + var result = FrontendCalcReference.CalcUnwind(input); + + // MarkClosePnl = 1000×(1.05−1.02)×1×1 = 30 + AssertDecimalEqual(30m, result.MarkClosePnl, 0.01m, "MarkClosePnl"); + // FloatPnlSum = 30 + 20 + 0 + 0 = 50 + AssertDecimalEqual(50m, result.FloatPnlSum, 0.01m, "FloatPnlSum"); + // SwapRealizedPnL = FloatPnlSum(50) + AssertDecimalEqual(50m, result.SwapRealizedPnL, 0.01m, "SwapRealizedPnL"); + Console.WriteLine($"FC_001: MarkClosePnl={result.MarkClosePnl}, FloatPnlSum={result.FloatPnlSum} ✅"); + } + + /// + /// [FC_002] 平仓-用户改标的价格(TradingAmountAvg 100→110) + /// MarkClosePnl = round(1000×(110×0.01−1.02)×10000)/10000 = round(1000×0.08×10000)/10000 = 80 + /// + [TestMethod] + public void FC_002_平仓_用户改标的价格() + { + var input = new UnwindInput + { + Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 110m, // 改成110 + CloseQty = 1000, PayDirection = 1, PositionType = 1, + TradingFee = "20", TradingFeePending = "0", DividendIn = "0" + }; + var result = FrontendCalcReference.CalcUnwind(input); + + AssertDecimalEqual(80m, result.MarkClosePnl, 0.01m, "改价格后 MarkClosePnl"); + AssertDecimalEqual(100m, result.FloatPnlSum, 0.01m, "改价格后 FloatPnlSum"); + Console.WriteLine($"FC_002: 改标的价格后 MarkClosePnl={result.MarkClosePnl} ✅"); + } + + /// + /// [FC_003] 平仓-用户改平仓数量(CloseQty 1000→500,TradingFeePending 随比例变) + /// MarkClosePnl = round(500×(105×0.01−1.02)×10000)/10000 = round(500×0.03×10000)/10000 = 15 + /// TradingFeePending 按比例=BeforeCloseFee×ClosePercent(0.5),假设=10 + /// + [TestMethod] + public void FC_003_平仓_用户改平仓数量() + { + var input = new UnwindInput + { + Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m, + CloseQty = 500, // 改成500(原1000) + PayDirection = 1, PositionType = 1, + TradingFee = "20", TradingFeePending = "10", DividendIn = "0" + }; + var result = FrontendCalcReference.CalcUnwind(input); + + // MarkClosePnl = 500×0.03 = 15 + AssertDecimalEqual(15m, result.MarkClosePnl, 0.01m, "改数量后 MarkClosePnl"); + // FloatPnlSum = 15 + 20 + 10 + 0 = 45 + AssertDecimalEqual(45m, result.FloatPnlSum, 0.01m, "改数量后 FloatPnlSum"); + Console.WriteLine($"FC_003: 改平仓数量后 MarkClosePnl={result.MarkClosePnl} ✅"); + } + + /// + /// [FC_004] 平仓-用户改利息金额(InterestClosePnL=100) + /// SwapRealizedPnL = FloatPnlSum(50) + InterestClosePnL(100) = 150 + /// + [TestMethod] + public void FC_004_平仓_用户改利息金额() + { + var input = new UnwindInput + { + Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m, + CloseQty = 1000, PayDirection = 1, PositionType = 1, + TradingFee = "20", TradingFeePending = "0", DividendIn = "0" + }; + input.InterestLegs.Add(new LegInput { InterestClosePnL = 100m }); + + var result = FrontendCalcReference.CalcUnwind(input); + + AssertDecimalEqual(30m, result.MarkClosePnl, 0.01m, "MarkClosePnl 不受利息影响"); + // SwapRealizedPnL = 50 + 100 = 150 + AssertDecimalEqual(150m, result.SwapRealizedPnL, 0.01m, "含利息的 SwapRealizedPnL"); + Console.WriteLine($"FC_004: 改利息后 SwapRealizedPnL={result.SwapRealizedPnL} ✅"); + } + + /// + /// [FC_005] 平仓-非债券空头(PositionType=Short=2, multiplier=1) + /// floatRatio=1(收取), longRatio=-1(空头) + /// MarkClosePnl = round(1000×(100×1−100)×1×(−1)×10000)/10000 = 0(价格不变时空头盈亏=0) + /// 改成价格涨:TradingAmountAvg=105, MarkClosePnl=round(1000×(105−100)×1×(−1)×10000)/10000=−50000 + /// 空头价格涨=亏损 + /// + [TestMethod] + public void FC_005_平仓_非债券空头_方向因子() + { + var input = new UnwindInput + { + Multiplier = 1, PosiGrossPrice = 100m, TradingAmountAvg = 105m, // 涨了5 + CloseQty = 1000, PayDirection = 1, PositionType = 2, // 空头 + TradingFee = "0", TradingFeePending = "0", DividendIn = "0" + }; + var result = FrontendCalcReference.CalcUnwind(input); + + // 空头价格涨=亏损:1000×(105−100)×1×(−1) = −5000 + AssertDecimalEqual(-5000m, result.MarkClosePnl, 0.01m, "空头价格涨=亏损"); + Console.WriteLine($"FC_005: 空头方向因子 MarkClosePnl={result.MarkClosePnl} ✅"); + } + + // ---- 结息页(income)场景 ---- + + /// + /// [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 + /// + [TestMethod] + public void FC_006_结息_债券多头_全量结算() + { + var input = new UnwindInput + { + Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m, + CloseNotionalValue = 10000, // income 用名义本金 + CloseQty = 0, // income 不用数量 + PayDirection = 1, PositionType = 1, + TradingFee = "0", TradingFeePending = "0", DividendIn = "0" + }; + var result = FrontendCalcReference.CalcIncome(input); + + AssertDecimalEqual(300m, result.MarkClosePnl, 0.01m, "income MarkClosePnl"); + AssertDecimalEqual(300m, result.SwapRealizedPnL, 0.01m, "income SwapRealizedPnL"); + Console.WriteLine($"FC_006: income MarkClosePnl={result.MarkClosePnl} ✅"); + } + + /// + /// [FC_007] 结息-用户改标的价格(TradingAmountAvg 105→110) + /// MarkClosePnl = 10000×(110×0.01−1.02) = 10000×0.08 = 800 + /// + [TestMethod] + public void FC_007_结息_用户改标的价格() + { + var input = new UnwindInput + { + Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 110m, + CloseNotionalValue = 10000, CloseQty = 0, + PayDirection = 1, PositionType = 1, + TradingFee = "0", TradingFeePending = "0", DividendIn = "0" + }; + var result = FrontendCalcReference.CalcIncome(input); + + AssertDecimalEqual(800m, result.MarkClosePnl, 0.01m, "改价格后 income MarkClosePnl"); + Console.WriteLine($"FC_007: 改价格后 income MarkClosePnl={result.MarkClosePnl} ✅"); + } + + /// + /// [FC_008] 结息-含利息腿与预付金腿(InterestClosePnL + margin InterestClosePnL) + /// SwapRealizedPnL = FloatPnlSum(300) + 利息腿(100) + 预付金腿(50) = 450 + /// SwapMarginRebatePnl = 预付金腿(50) + /// + [TestMethod] + public void FC_008_结息_含利息腿与预付金腿_总额() + { + var input = new UnwindInput + { + Multiplier = 100, PosiGrossPrice = 1.02m, TradingAmountAvg = 105m, + CloseNotionalValue = 10000, CloseQty = 0, + PayDirection = 1, PositionType = 1, + TradingFee = "0", TradingFeePending = "0", DividendIn = "0" + }; + input.InterestLegs.Add(new LegInput { InterestClosePnL = 100m }); + input.MarginLegs.Add(new LegInput { InterestClosePnL = 50m }); + + var result = FrontendCalcReference.CalcIncome(input); + + // SwapRealizedPnL = 300 + 100 + 50 = 450 + AssertDecimalEqual(450m, result.SwapRealizedPnL, 0.01m, "含利息+预付金的 SwapRealizedPnL"); + // SwapMarginRebatePnl = 50 + AssertDecimalEqual(50m, result.SwapMarginRebatePnl, 0.01m, "SwapMarginRebatePnl"); + Console.WriteLine($"FC_008: SwapRealizedPnL={result.SwapRealizedPnL}, SwapMarginRebatePnl={result.SwapMarginRebatePnl} ✅"); + } + + private static void AssertDecimalEqual(decimal expected, decimal actual, decimal tolerance, string message = "") + { + Assert.IsTrue(Math.Abs(expected - actual) <= tolerance, + $"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}"); + } + } +}