前端保持快速反馈(用户改输入立即算),后端不替代前端,仅做合理性兜底校验。 1. 搬FrontendCalcReference到YLErpDAL/Helpers(测试与生产共用同一份公式,避免分叉) - 从FrontendCalcCharacterizationTest提取,改public,8个FC测试搬迁后全绿验证一致 2. SwapDealService新增ValidateFrontendPnL只读校验: - 从unwindData.FlowEvents取浮动腿,PosiGrossPrice==0时跳过(避免误报) - 调FrontendCalcReference重算,与前端传值逐字段比对 - 差异>0.01记Logger.Error(带输入快照便于排查) - 整体try/catch吞异常,校验自身错误绝不阻断交易 - SwapUnwind/SwapIncome各插一处(FindTrade后、ExecuteInTransaction前) 不碰NLog配置(告警进Console,生产由运维采集)。SwapModule 157测试全绿。
235 lines
12 KiB
C#
235 lines
12 KiB
C#
using Newtonsoft.Json;
|
||
using Newtonsoft.Json.Linq;
|
||
using YLErp.DBModels;
|
||
using YLErp.DBModels.Enums;
|
||
using YLErp.Helpers;
|
||
|
||
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 已搬迁到生产代码 YLErpDAL/Helpers/FrontendCalcReference.cs,
|
||
// 生产代码(SwapDealService校验)与测试共用同一份公式实现,避免分叉。
|
||
|
||
// ================================================================
|
||
// 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.01−1.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.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} ✅");
|
||
}
|
||
|
||
/// <summary>
|
||
/// [FC_002] 平仓-用户改标的价格(TradingAmountAvg 100→110)
|
||
/// MarkClosePnl = round(1000×(110×0.01−1.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→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
|
||
/// </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×1−100)×1×(−1)×10000)/10000 = 0(价格不变时空头盈亏=0)
|
||
/// 改成价格涨:TradingAmountAvg=105, MarkClosePnl=round(1000×(105−100)×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×(105−100)×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.01−1.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.01−1.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}");
|
||
}
|
||
}
|
||
}
|