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}"); } } }