Files
zszq-trs/UnitTestProject/Modules/SwapModule/FrontendCalcCharacterizationTest.cs
T
hjhan cf0d965ff8 test(swap): 前端计算逻辑特征化测试(8场景含用户可变输入)
用C#忠实重写前端JS公式作参考实现(FrontendCalcReference),冻结前端计算行为,
为后端校验/重算兜底提供金标准(注:前端保持快速反馈,不搬到后端)。

8个场景覆盖用户可变输入分支:
- FC_001-005 平仓页: 默认值/改标的价格/改平仓数量/改利息金额/非债券空头方向因子
- FC_006-008 结息页: 全量结算/改标的价格/含利息腿与预付金腿总额

参考实现内部用规范命名(EntryDirtyPrice/ExitDirtyFeePrice/floatRatio/longRatio),
践行《互换价格字段命名规范决策文档》,作命名规范活样板。
2026-07-03 08:22:55 +08:00

404 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 前端计算逻辑特征化测试(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# 忠实重写(参考实现,非生产代码)
// ================================================================
/// <summary>
/// 前端公式参考实现。输入=前端可见的原始字段,输出=前端算出的衍生字段。
/// 注意:otcformat.trading.StockEqvNotional 的精度按 MoneyRound=2 模拟。
/// </summary>
private static class FrontendCalcReference
{
/// <summary>模拟 otcformat.trading.StockEqvNotional(金额类,2位小数)</summary>
private static decimal StockEqvNotional(decimal v) => Math.Round(v, 2, MidpointRounding.AwayFromZero);
/// <summary>
/// 计算平仓页(unwind)的盯市盈亏与汇总。
/// 对应 unwindSwapTrade.js:196-261calcFloatClosePnl + calcCloseAmount)。
/// </summary>
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=收取)→+1PositionType(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"));
// calcCloseAmountSwapRealizedPnL/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:-1calcCloseAmount 内重新定义)
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
};
}
/// <summary>
/// 计算结息页(income)的盯市盈亏与汇总。
/// 对应 incomeSwapTrade.js:128-178。
/// 差异:用 CloseNotionalValue(非 CloseQty)作量纲,无 longRatio,无 Math.round/10000。
/// </summary>
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 用 floatRatioPayDirection),与 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<LegInput> InterestLegs = new();
public List<LegInput> 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)场景 ----
/// <summary>
/// [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.011.02)×1×1×10000)/10000 = round(1000×0.03×10000)/10000 = 30
/// </summary>
[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.051.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} ✅");
}
/// <summary>
/// [FC_002] 平仓-用户改标的价格(TradingAmountAvg 100→110
/// MarkClosePnl = round(1000×(110×0.011.02)×10000)/10000 = round(1000×0.08×10000)/10000 = 80
/// </summary>
[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} ✅");
}
/// <summary>
/// [FC_003] 平仓-用户改平仓数量(CloseQty 1000→500TradingFeePending 随比例变)
/// MarkClosePnl = round(500×(105×0.011.02)×10000)/10000 = round(500×0.03×10000)/10000 = 15
/// TradingFeePending 按比例=BeforeCloseFee×ClosePercent(0.5),假设=10
/// </summary>
[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} ✅");
}
/// <summary>
/// [FC_004] 平仓-用户改利息金额(InterestClosePnL=100
/// SwapRealizedPnL = FloatPnlSum(50) + InterestClosePnL(100) = 150
/// </summary>
[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} ✅");
}
/// <summary>
/// [FC_005] 平仓-非债券空头(PositionType=Short=2, multiplier=1
/// floatRatio=1(收取), longRatio=-1(空头)
/// MarkClosePnl = round(1000×(100×1100)×1×(1)×10000)/10000 = 0(价格不变时空头盈亏=0)
/// 改成价格涨:TradingAmountAvg=105, MarkClosePnl=round(1000×(105100)×1×(1)×10000)/10000=50000
/// 空头价格涨=亏损
/// </summary>
[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×(105100)×1×(1) = 5000
AssertDecimalEqual(-5000m, result.MarkClosePnl, 0.01m, "空头价格涨=亏损");
Console.WriteLine($"FC_005: 空头方向因子 MarkClosePnl={result.MarkClosePnl} ✅");
}
// ---- 结息页(income)场景 ----
/// <summary>
/// [FC_006] 结息-债券多头-全量结算(基线)
/// income 用 CloseNotionalValue 而非 CloseQty,无 longRatio
/// EntryPrice=1.02, TradingAmountAvg=105(×100形态), CloseNotionalValue=10000
/// MarkClosePnl = 10000×(105×0.011.02)×1 = 10000×0.03 = 300
/// </summary>
[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} ✅");
}
/// <summary>
/// [FC_007] 结息-用户改标的价格(TradingAmountAvg 105→110
/// MarkClosePnl = 10000×(110×0.011.02) = 10000×0.08 = 800
/// </summary>
[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} ✅");
}
/// <summary>
/// [FC_008] 结息-含利息腿与预付金腿(InterestClosePnL + margin InterestClosePnL
/// SwapRealizedPnL = FloatPnlSum(300) + 利息腿(100) + 预付金腿(50) = 450
/// SwapMarginRebatePnl = 预付金腿(50)
/// </summary>
[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}");
}
}
}