Files
zszq-trs/UnitTestProject/Modules/SwapModule/SwapFrontendPnlValidateTest.cs
hjhan a179675efd refactor(swap-deal): 前端盈亏校验抽为 SwapFrontendPnlValidator
BuildFrontendValidationDiffs/FrontendPnlDiff/AddDiffIfOverThreshold 从2043行 SwapDealService 移至新文件,本文件仅留调用+日志。纯位置迁移,零行为变更。
2026-07-21 15:28:14 +08:00

317 lines
16 KiB
C#
Raw Permalink 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 YLErp.DBModels;
using YLErp.DBModels.Enums;
using YLErp.Helpers;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// SwapFrontendPnlValidator.BuildFrontendValidationDiffs 的回归测试。
/// -----------------------------------------------------------------
/// 守卫提交 41553970 "fix(swap): 修复债券结息价差盈亏计算逻辑"。
///
/// 背景:ValidateFrontendPnL 用 FrontendCalcReference 重算盈亏与前端值比对,
/// 原为 private void + 吞异常,无法单测。拆出 BuildFrontendValidationDiffs 纯函数:
/// - 入参:UnwindData + isIncome
/// - 返回:null(前置条件不满足)或 List&lt;FrontendPnlDiff&gt;(超阈值的差异项)
/// - 副作用:无(日志留在 ValidateFrontendPnL 外层)
///
/// 本测试锁定:
/// 1) 前端值与后端重算一致 → 返回空列表
/// 2) 前端值与后端重算不一致 → 返回对应字段差异
/// 3) 无浮动腿 → 返回 null
/// 4) PosiGrossPrice=0 → 返回 null
/// 5) PositionQty/CloseQty 口径(41553970 修复点)正确传入
/// </summary>
[TestClass]
public class SwapFrontendPnlValidateTest
{
// ================================================================
// 场景1:前端值与后端重算一致 → 返回空列表
// 用 FC_001 同款输入:债券多头,PosiGrossPrice=1.02, TradingAmountAvg=105,
// CloseQty=1000, PayDirection=1, PositionType=1, TradingFee="20"
// 后端重算:MarkClosePnl=30, FloatPnlSum=50, SwapRealizedPnL=50, SwapCloseAmount=50
// 前端也填这些值 → 无差异
// ================================================================
[TestMethod]
public void 前后端一致_返回空差异列表()
{
var unwindData = BuildBaseUnwindData(
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
closeQty: 1000, positionQty: 1000,
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
payDirection: 1, positionType: 1,
swapRealizedPnL: 50m, swapCloseAmount: 50m, markClosePnl: 30m);
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false);
Assert.IsNotNull(diffs, "前置条件满足应返回列表而非 null");
Assert.AreEqual(0, diffs.Count,
$"前后端一致应无差异,实际 {diffs.Count} 条:{string.Join(",", diffs.Select(d => d.Field))}");
}
// ================================================================
// 场景2SwapRealizedPnL 前端填错 → 返回该字段差异
// 后端重算 SwapRealizedPnL=50, SwapCloseAmount=50
// 前端 SwapRealizedPnL 故意填 60SwapCloseAmount 保持 50 一致)→ 只 SwapRealizedPnL 有差异
// ================================================================
[TestMethod]
public void SwapRealizedPnL前端填错_返回该字段差异()
{
var unwindData = BuildBaseUnwindData(
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
closeQty: 1000, positionQty: 1000,
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
payDirection: 1, positionType: 1,
swapRealizedPnL: 60m, // 故意填错(正确=50
swapCloseAmount: 50m, // 保持一致
markClosePnl: 30m); // 保持一致
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false);
Assert.IsNotNull(diffs);
CollectionAssert.AreEquivalent(
new[] { "SwapRealizedPnL" },
diffs.Select(d => d.Field).ToArray(),
"应只捕获 SwapRealizedPnL 的差异");
var diff = diffs.Single(d => d.Field == "SwapRealizedPnL");
Assert.AreEqual(60m, diff.FrontendValue, "前端值=60");
Assert.AreEqual(50m, diff.BackendValue, 0.01m, "后端重算=50");
Assert.AreEqual(10m, diff.Delta, 0.01m, "Delta=10");
}
// ================================================================
// 场景3MarkClosePnl 前端填错 → 返回该字段差异
// 后端重算 MarkClosePnl=30;前端故意填 25(其他保持一致)→ 只 MarkClosePnl 有差异
// ================================================================
[TestMethod]
public void MarkClosePnl前端填错_返回该字段差异()
{
var unwindData = BuildBaseUnwindData(
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
closeQty: 1000, positionQty: 1000,
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
payDirection: 1, positionType: 1,
swapRealizedPnL: 50m,
swapCloseAmount: 50m,
markClosePnl: 25m); // 故意填错(正确=30
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false);
Assert.IsNotNull(diffs);
CollectionAssert.AreEquivalent(
new[] { "MarkClosePnl" },
diffs.Select(d => d.Field).ToArray(),
"应只捕获 MarkClosePnl 的差异");
var diff = diffs.Single(d => d.Field == "MarkClosePnl");
Assert.AreEqual(25m, diff.FrontendValue);
Assert.AreEqual(30m, diff.BackendValue, 0.01m);
Assert.AreEqual(-5m, diff.Delta, 0.01m);
}
// ================================================================
// 场景4:无浮动腿(FlowEvents 为空)→ 返回 null
// ================================================================
[TestMethod]
public void 无浮动腿_返回null()
{
var unwindData = new UnwindData
{
SwapTradeId = 1,
CloseQty = 1000,
PositionQty = 1000,
FlowEvents = new List<swap_flow_event>() // 完全空
};
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false);
Assert.IsNull(diffs, "无浮动腿应返回 null(跳过校验)");
}
// ================================================================
// 场景5:浮动腿 PosiGrossPrice=0 → 返回 null(避免误报)
// ================================================================
[TestMethod]
public void 浮动腿PosiGrossPrice为零_返回null()
{
var unwindData = BuildBaseUnwindData(
posiGrossPrice: 0m, // 前端未传 → 0
tradingAmountAvg: 105m,
closeQty: 1000, positionQty: 1000,
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
payDirection: 1, positionType: 1,
swapRealizedPnL: 50m, swapCloseAmount: 0m, markClosePnl: 30m);
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false);
Assert.IsNull(diffs, "PosiGrossPrice=0 应返回 null(避免误报)");
}
// ================================================================
// 场景641553970 修复点 —— PositionQty 必须正确传入后端重算
// 旧 bugPositionQty 未传入,导致部分平仓时盈亏口径错误。
// 验证:PositionQty != CloseQty 时,后端重算仍按真实 PositionQty 走
// (本场景构造部分平仓:CloseQty=500, PositionQty=1000
// 平仓页 unwind 用 CloseQty 算 MarkClosePnl
// MarkClosePnl = 500×(1.051.02)×1×1 = 15
// FloatPnlSum = 15 + 20 + 0 + 0 = 35
// SwapRealizedPnL = SwapCloseAmount = 35
// ================================================================
[TestMethod]
public void 部分平仓_PositionQty正确传入后端重算()
{
var unwindData = BuildBaseUnwindData(
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
closeQty: 500, positionQty: 1000,
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
payDirection: 1, positionType: 1,
swapRealizedPnL: 35m, // 与后端重算一致
swapCloseAmount: 35m, // 与后端重算一致
markClosePnl: 15m); // 与后端重算一致
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false);
Assert.IsNotNull(diffs);
Assert.AreEqual(0, diffs.Count,
$"部分平仓 PositionQty 正确传入应无差异,实际 {diffs.Count} 条:" +
string.Join(",", diffs.Select(d => $"{d.Field}(fe={d.FrontendValue},be={d.BackendValue})")));
}
// ================================================================
// 场景7:阈值边界 —— 差异恰好等于阈值(0.01)不报,超过才报
// 后端重算 SwapRealizedPnL=50, SwapCloseAmount=50
// 前端 SwapRealizedPnL 填 50.01 → 差异 0.01 不> 0.01 → 不报
// 前端 SwapRealizedPnL 填 50.02 → 差异 0.02 > 0.01 → 报
// SwapCloseAmount 保持 50 一致,不参与本场景断言)
// ================================================================
[TestMethod]
public void 阈值边界_差异等于阈值不报_超过才报()
{
// 差异 = 0.01,不 > 0.01,不报
var unwindDataEq = BuildBaseUnwindData(
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
closeQty: 1000, positionQty: 1000,
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
payDirection: 1, positionType: 1,
swapRealizedPnL: 50.01m, swapCloseAmount: 50m, markClosePnl: 30m);
var diffsEq = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindDataEq, isIncome: false);
Assert.IsNotNull(diffsEq);
Assert.IsFalse(diffsEq.Any(d => d.Field == "SwapRealizedPnL"),
"差异=0.01 不> 阈值,不应报 SwapRealizedPnL");
// 差异 = 0.02 > 0.01,报
var unwindDataOver = BuildBaseUnwindData(
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
closeQty: 1000, positionQty: 1000,
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
payDirection: 1, positionType: 1,
swapRealizedPnL: 50.02m, swapCloseAmount: 50m, markClosePnl: 30m);
var diffsOver = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindDataOver, isIncome: false);
Assert.IsNotNull(diffsOver);
Assert.IsTrue(diffsOver.Any(d => d.Field == "SwapRealizedPnL"),
"差异=0.02 > 阈值,应报 SwapRealizedPnL");
}
// ================================================================
// 场景8isIncome=true 走 CalcIncome 路径 —— 确保分支选择正确
// 结息页公式与平仓页不同,构造一致场景验证不抛异常且返回列表
// ================================================================
[TestMethod]
public void IsIncome为true_走CalcIncome分支_返回列表()
{
// income 页 MarkClosePnl = PositionQty × ContractSize × (ExitPrice×scale EntryPrice) × floatRatio
// = 1000 × 1 × (105×0.01 1.02) × 1 = 30
// FloatPnlSum = 30 + 20 = 50SwapRealizedPnL = SwapCloseAmount = 50
var unwindData = BuildBaseUnwindData(
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
closeQty: 1000, positionQty: 1000,
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
payDirection: 1, positionType: 1,
swapRealizedPnL: 50m, swapCloseAmount: 50m, markClosePnl: 30m);
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: true);
Assert.IsNotNull(diffs, "isIncome=true 也应返回列表(可能为空或有差异)");
// 不锁死具体差异,只验证分支可达、不抛异常
}
// ================================================================
// 场景9:自定义阈值 —— threshold=1.0 时小差异不报
// 后端 SwapRealizedPnL=50, SwapCloseAmount=50
// 前端 SwapRealizedPnL=50.5(差异 0.5 < 1.0 不报),SwapCloseAmount=50 一致
// ================================================================
[TestMethod]
public void 自定义大阈值_小差异不报()
{
var unwindData = BuildBaseUnwindData(
posiGrossPrice: 1.02m, tradingAmountAvg: 105m,
closeQty: 1000, positionQty: 1000,
tradingFee: "20", tradingFeePending: "0", dividendIn: "0",
payDirection: 1, positionType: 1,
swapRealizedPnL: 50.5m, // 差异 0.5
swapCloseAmount: 50m,
markClosePnl: 30m);
var diffs = SwapFrontendPnlValidator.BuildFrontendValidationDiffs(unwindData, isIncome: false, threshold: 1.0m);
Assert.IsNotNull(diffs);
Assert.IsFalse(diffs.Any(d => d.Field == "SwapRealizedPnL"),
"threshold=1.0 时差异 0.5 不应报");
}
// ================================================================
// Helper:构造带一条浮动腿 + 一条利息腿的 UnwindData
// 默认用债券(UnderlyingInstrumentType 走 IsBond=true → multiplier=100
// 字段值与 FrontendCalcCharacterizationTest.FC_001 对齐
// ================================================================
private static UnwindData BuildBaseUnwindData(
decimal posiGrossPrice,
decimal tradingAmountAvg,
decimal closeQty,
decimal positionQty,
string tradingFee,
string tradingFeePending,
string dividendIn,
int payDirection,
int positionType,
decimal swapRealizedPnL,
decimal swapCloseAmount,
decimal markClosePnl)
{
// 浮动腿(债券,有 UnderlyingCode
var floatLeg = new swap_flow_event
{
UnderlyingCode = "511160.SH",
UnderlyingInstrumentType = "Bond",
PosiGrossPrice = posiGrossPrice,
TradingAmountAvg = tradingAmountAvg,
ContractSize = 1m,
PayDirection = payDirection,
PositionType = positionType,
TradingFee = decimal.Parse(tradingFee),
TradingFeePending = decimal.Parse(tradingFeePending),
DividendIn = decimal.Parse(dividendIn),
MarkClosePnl = markClosePnl,
InterestMode = (int)InterestModeEnum.标的期初全价
};
// 利息腿(无 UnderlyingCode
var interestLeg = new swap_flow_event
{
InterestMode = (int)InterestModeEnum.固定值,
InterestClosePnL = 0m
};
return new UnwindData
{
SwapTradeId = 1,
CloseQty = closeQty,
PositionQty = positionQty,
CloseNotionalValue = closeQty * 100m, // 债券面值 100
SwapRealizedPnL = swapRealizedPnL,
SwapCloseAmount = swapCloseAmount,
FlowEvents = new List<swap_flow_event> { floatLeg, interestLeg }
};
}
}
}