test(swap): 前端计算逻辑特征化测试(8场景含用户可变输入)

用C#忠实重写前端JS公式作参考实现(FrontendCalcReference),冻结前端计算行为,
为后端校验/重算兜底提供金标准(注:前端保持快速反馈,不搬到后端)。

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

参考实现内部用规范命名(EntryDirtyPrice/ExitDirtyFeePrice/floatRatio/longRatio),
践行《互换价格字段命名规范决策文档》,作命名规范活样板。
This commit is contained in:
hjhan
2026-07-03 08:22:55 +08:00
parent a207927f1b
commit cf0d965ff8
@@ -0,0 +1,403 @@
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}");
}
}
}