refactor(swap-eod): 抽出期末头寸纯函数并放宽可见性

SetFixedLegRealizedPnl 替换4处复制粘贴(清理L1296双分号); NormalizeInterestSignForReport 替换16行内联报表分支; CalculateWeightedMarginInterest private->public static。均为纯静态无实例依赖,配套无库单测锁定行为。
This commit is contained in:
hjhan
2026-07-21 15:28:13 +08:00
parent 494a7a06cc
commit a153a1d1a6
4 changed files with 566 additions and 21 deletions
@@ -0,0 +1,162 @@
using YLErp.DBModels;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// SwapEodPositionService.SetFixedLegRealizedPnl 的回归测试。
/// -----------------------------------------------------------------
/// 守卫提交 f4ffe710 "fix(swap): 修复期末头寸已实现盈亏计算问题"。
///
/// 4 处 SaveAutoEodInterestPosition/SaveEodInterestPosition 路径原本各自复制粘贴:
/// newEodPayPosition.RealizedPnl = RealizedInterest + RealizedInterestFee
/// 其中 L1296 还遗留了双分号笔误 ";;"。抽为单一纯函数后消除复制粘贴风险,
/// 并锁定"固定利息腿累计已实现盈亏 = 累计利息 + 累计利息费用"口径。
///
/// 单测覆盖:正/负/零/混合符号、大额、InterestFee 为零等场景。
/// </summary>
[TestClass]
public class SwapFixedLegRealizedPnlTest
{
// ================================================================
// 场景1:收取方向,利息与利息费用均为正
// ================================================================
[TestMethod]
public void _利息与费用均为正_求和()
{
var pos = NewPosition(realizedInterest: 1000m, realizedInterestFee: 200m);
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
Assert.AreEqual(1200m, pos.RealizedPnl, 0.0001m,
"1000 + 200 = 1200");
}
// ================================================================
// 场景2:支付方向,利息与利息费用均为负
// ================================================================
[TestMethod]
public void _利息与费用均为负_求和()
{
var pos = NewPosition(realizedInterest: -1000m, realizedInterestFee: -200m);
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
Assert.AreEqual(-1200m, pos.RealizedPnl, 0.0001m,
"-1000 + (-200) = -1200");
}
// ================================================================
// 场景3:利息费用为零 —— RealizedPnl = RealizedInterest
// 回归场景:部分路径利息费用未发生,确保不误乘/不丢值
// ================================================================
[TestMethod]
public void _盈亏等于利息()
{
var pos = NewPosition(realizedInterest: 500m, realizedInterestFee: 0m);
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
Assert.AreEqual(500m, pos.RealizedPnl, 0.0001m,
"利息费用=0 时 RealizedPnl = RealizedInterest");
}
// ================================================================
// 场景4:利息为零 —— RealizedPnl = RealizedInterestFee
// ================================================================
[TestMethod]
public void _盈亏等于利息费用()
{
var pos = NewPosition(realizedInterest: 0m, realizedInterestFee: 300m);
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
Assert.AreEqual(300m, pos.RealizedPnl, 0.0001m,
"利息=0 时 RealizedPnl = RealizedInterestFee");
}
// ================================================================
// 场景5:两者均为零 —— RealizedPnl = 0
// ================================================================
[TestMethod]
public void _盈亏为零()
{
var pos = NewPosition(realizedInterest: 0m, realizedInterestFee: 0m);
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
Assert.AreEqual(0m, pos.RealizedPnl, 0.0001m,
"两者均为 0 时 RealizedPnl = 0");
}
// ================================================================
// 场景6:混合符号(利息负、利息费用正)—— 直接求和
// 回归场景:避免有人误加 Math.Abs 或方向判断
// ================================================================
[TestMethod]
public void _利息负费用正_直接求和()
{
var pos = NewPosition(realizedInterest: -800m, realizedInterestFee: 100m);
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
Assert.AreEqual(-700m, pos.RealizedPnl, 0.0001m,
"-800 + 100 = -700,不引入 Abs/方向判断");
}
// ================================================================
// 场景7:大额 —— 验证 decimal 精度无溢出
// ================================================================
[TestMethod]
public void _decimal精度无溢出()
{
var pos = NewPosition(realizedInterest: 279_486_108.21m, realizedInterestFee: 13_668.02m);
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
Assert.AreEqual(279_499_776.23m, pos.RealizedPnl, 0.0001m,
"大额 decimal 求和精度保持");
}
// ================================================================
// 场景8null 入参 —— 抛 NullReferenceException(现状锚点)
// 生产代码未加 null 检查,直接解引用 position 抛 NRE。
// 若未来改为 ArgumentNullException,此处需同步更新。
// ================================================================
[TestMethod]
public void Null入参_抛NullReferenceException()
{
Assert.ThrowsException<NullReferenceException>(() =>
SwapEodPositionService.SetFixedLegRealizedPnl(null!));
}
// ================================================================
// 场景9:覆盖原值 —— 验证是赋值而非累加
// 回归场景:防止有人误改为 += 导致重复计算
// ================================================================
[TestMethod]
public void RealizedPnl被覆盖_非累加()
{
var pos = NewPosition(realizedInterest: 100m, realizedInterestFee: 50m);
pos.RealizedPnl = 9999m; // 预置一个非零旧值
SwapEodPositionService.SetFixedLegRealizedPnl(pos);
Assert.AreEqual(150m, pos.RealizedPnl, 0.0001m,
"应直接覆盖为 150,而非累加旧值 9999");
}
// ================================================================
// Helper:构造 eod_swap_position(只设置参与计算的 2 个字段)
// ================================================================
private static eod_swap_position NewPosition(
decimal realizedInterest,
decimal realizedInterestFee)
{
return new eod_swap_position
{
RealizedInterest = realizedInterest,
RealizedInterestFee = realizedInterestFee
};
}
}
}
@@ -0,0 +1,212 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// SwapEodPositionService.NormalizeInterestSignForReport 的回归测试。
/// -----------------------------------------------------------------
/// 守卫提交 f4ffe710 中报表分支的符号归一化逻辑(原内联于 SearchEodSwapList)。
///
/// 业务口径:
/// - 仅当 InterestDirection > 0 时执行(兼容历史 0 方向脏数据)
/// - 普通利息腿:收取为正、支付为负(interestRatio = Direction==收取 ? 1 : -1
/// - 预付金腿(初始预付金/追加预付金):利息方向与保证金本金方向相反,interestRatio 取反
/// - TdCloseInterest / RealizedInterest 统一按 Math.Abs × interestRatio 重写
/// - RealizedPnl 重算为 RealizedInterest + RealizedInterestFee(兼容历史未同步落库)
///
/// 抽为 public static 纯函数以支持无库单测。
/// </summary>
[TestClass]
public class SwapReportInterestSignNormalizeTest
{
// ================================================================
// 场景1:普通利息腿收取方向 → 利息维持正号
// TdCloseInterest=-1000(历史脏数据符号错) → 归一化为 +1000
// RealizedInterest=-2000 → +2000
// RealizedPnl = 2000 + 100 = 2100
// ================================================================
[TestMethod]
public void _收取方向_利息归一化为正()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: (int)SwapDirectionEnum.,
tdCloseInterest: -1000m,
realizedInterest: -2000m,
realizedInterestFee: 100m);
SwapEodPositionService.NormalizeInterestSignForReport(pos);
Assert.AreEqual(1000m, pos.TdCloseInterest, 0.0001m, "TdCloseInterest 应归一化为 +1000");
Assert.AreEqual(2000m, pos.RealizedInterest, 0.0001m, "RealizedInterest 应归一化为 +2000");
Assert.AreEqual(2100m, pos.RealizedPnl, 0.0001m, "RealizedPnl = 2000 + 100 = 2100");
}
// ================================================================
// 场景2:普通利息腿支付方向 → 利息归一化为负
// TdCloseInterest=1000(历史脏数据符号错) → 归一化为 -1000
// RealizedInterest=2000 → -2000
// RealizedPnl = -2000 + 100 = -1900
// ================================================================
[TestMethod]
public void _支付方向_利息归一化为负()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: (int)SwapDirectionEnum.,
tdCloseInterest: 1000m,
realizedInterest: 2000m,
realizedInterestFee: 100m);
SwapEodPositionService.NormalizeInterestSignForReport(pos);
Assert.AreEqual(-1000m, pos.TdCloseInterest, 0.0001m, "TdCloseInterest 应归一化为 -1000");
Assert.AreEqual(-2000m, pos.RealizedInterest, 0.0001m, "RealizedInterest 应归一化为 -2000");
Assert.AreEqual(-1900m, pos.RealizedPnl, 0.0001m, "RealizedPnl = -2000 + 100 = -1900");
}
// ================================================================
// 场景3:预付金腿收取方向 → 利息方向反向为负
// 原因:预付金腿的利息方向与保证金本金方向相反
// TdCloseInterest=1000 → -1000
// RealizedInterest=2000 → -2000
// ================================================================
[TestMethod]
public void _收取方向_利息反向为负()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: (int)SwapDirectionEnum.,
tdCloseInterest: 1000m,
realizedInterest: 2000m,
realizedInterestFee: 0m);
SwapEodPositionService.NormalizeInterestSignForReport(pos);
Assert.AreEqual(-1000m, pos.TdCloseInterest, 0.0001m,
"预付金腿收取方向:利息反向为负");
Assert.AreEqual(-2000m, pos.RealizedInterest, 0.0001m,
"预付金腿收取方向:累计利息反向为负");
Assert.AreEqual(-2000m, pos.RealizedPnl, 0.0001m,
"RealizedPnl = -2000 + 0 = -2000");
}
// ================================================================
// 场景4:预付金腿支付方向 → 利息方向反向为正
// ================================================================
[TestMethod]
public void _支付方向_利息反向为正()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: (int)SwapDirectionEnum.,
tdCloseInterest: -1000m,
realizedInterest: -2000m,
realizedInterestFee: 50m);
SwapEodPositionService.NormalizeInterestSignForReport(pos);
Assert.AreEqual(1000m, pos.TdCloseInterest, 0.0001m,
"预付金腿支付方向:利息反向为正");
Assert.AreEqual(2000m, pos.RealizedInterest, 0.0001m,
"预付金腿支付方向:累计利息反向为正");
Assert.AreEqual(2050m, pos.RealizedPnl, 0.0001m,
"RealizedPnl = 2000 + 50 = 2050");
}
// ================================================================
// 场景5InterestDirection=0 → 不处理(兼容历史 0 方向脏数据)
// 所有字段保持原值不变
// ================================================================
[TestMethod]
public void _不处理_字段保持原值()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: 0,
tdCloseInterest: -999m,
realizedInterest: -888m,
realizedInterestFee: 77m);
pos.RealizedPnl = 555m; // 预置旧值
SwapEodPositionService.NormalizeInterestSignForReport(pos);
Assert.AreEqual(-999m, pos.TdCloseInterest, 0.0001m, "方向=0TdCloseInterest 不变");
Assert.AreEqual(-888m, pos.RealizedInterest, 0.0001m, "方向=0RealizedInterest 不变");
Assert.AreEqual(555m, pos.RealizedPnl, 0.0001m, "方向=0RealizedPnl 不重算");
}
// ================================================================
// 场景6InterestDirection 为负 → 不处理(防御性,对应原 "> 0" 判断)
// ================================================================
[TestMethod]
public void _不处理_字段保持原值()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: -1,
tdCloseInterest: -999m,
realizedInterest: -888m,
realizedInterestFee: 77m);
pos.RealizedPnl = 555m;
SwapEodPositionService.NormalizeInterestSignForReport(pos);
Assert.AreEqual(-999m, pos.TdCloseInterest, 0.0001m, "方向<0TdCloseInterest 不变");
Assert.AreEqual(555m, pos.RealizedPnl, 0.0001m, "方向<0RealizedPnl 不重算");
}
// ================================================================
// 场景7:利息为零 → Math.Abs(0)=0,归一化后仍为 0RealizedPnl=费用
// ================================================================
[TestMethod]
public void _归一化后仍为零_盈亏等于费用()
{
var pos = NewPosition(
interestMode: (int)InterestModeEnum.,
interestDirection: (int)SwapDirectionEnum.,
tdCloseInterest: 0m,
realizedInterest: 0m,
realizedInterestFee: 300m);
SwapEodPositionService.NormalizeInterestSignForReport(pos);
Assert.AreEqual(0m, pos.TdCloseInterest, 0.0001m);
Assert.AreEqual(0m, pos.RealizedInterest, 0.0001m);
Assert.AreEqual(300m, pos.RealizedPnl, 0.0001m, "RealizedPnl = 0 + 300 = 300");
}
// ================================================================
// 场景8null 入参 —— 抛 NullReferenceException(现状锚点)
// 生产代码未加 null 检查,InterestDirection 解引用即 NRE。
// 若未来改为 ArgumentNullException,此处需同步更新。
// ================================================================
[TestMethod]
public void Null入参_抛NullReferenceException()
{
Assert.ThrowsException<NullReferenceException>(() =>
SwapEodPositionService.NormalizeInterestSignForReport(null!));
}
// ================================================================
// Helper:构造 eod_swap_position
// ================================================================
private static eod_swap_position NewPosition(
int interestMode,
int interestDirection,
decimal tdCloseInterest,
decimal realizedInterest,
decimal realizedInterestFee)
{
return new eod_swap_position
{
InterestMode = interestMode,
InterestDirection = interestDirection,
TdCloseInterest = tdCloseInterest,
RealizedInterest = realizedInterest,
RealizedInterestFee = realizedInterestFee
};
}
}
}
@@ -0,0 +1,150 @@
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// SwapEodPositionService.CalculateWeightedMarginInterest 的回归测试。
/// -----------------------------------------------------------------
/// 守卫提交 a0f8dc9d "refactor(SwapModule): 简化预付金利息计算逻辑"。
///
/// 旧实现:按 InterestPrincipalFix 绝对值加权平均利率 × 总本金,对方向不敏感,
/// 当收取/支付双腿并存时会把支付方向的利息错误计为收益。
/// 新实现:InterestIncomeSum 已是各腿利息金额,按方向(收取=+1,支付=-1)轧差求和。
///
/// 抽为 public static 纯函数以支持无库单测。本测试锁定方向轧差契约。
/// </summary>
[TestClass]
public class SwapWeightedMarginInterestTest
{
// ================================================================
// 场景1:空集合 → 0(Sum 空序列默认值)
// ================================================================
[TestMethod]
public void _返回0()
{
var result = SwapEodPositionService.CalculateWeightedMarginInterest(
Enumerable.Empty<eod_swap_position>());
Assert.AreEqual(0m, result, 0.0001m, "空集合轧差应为 0");
}
// ================================================================
// 场景2:单腿收取 → InterestIncomeSum 原值
// ================================================================
[TestMethod]
public void _利息原值计入()
{
var margins = new[]
{
NewMargin(interestDirection: (int)SwapDirectionEnum., interestIncomeSum: 1000m)
};
var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins);
Assert.AreEqual(1000m, result, 0.0001m, "单腿收取:+1000");
}
// ================================================================
// 场景3:单腿支付 → InterestIncomeSum 取负
// ================================================================
[TestMethod]
public void _利息取负计入()
{
var margins = new[]
{
NewMargin(interestDirection: (int)SwapDirectionEnum., interestIncomeSum: 1000m)
};
var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins);
Assert.AreEqual(-1000m, result, 0.0001m, "单腿支付:-1000");
}
// ================================================================
// 场景4:双腿轧差(收取 1000 + 支付 600 → 400
// 旧 bug:按本金加权会忽略方向,结果不是 400
// ================================================================
[TestMethod]
public void _收取大于支付_净额为正()
{
var margins = new[]
{
NewMargin(interestDirection: (int)SwapDirectionEnum., interestIncomeSum: 1000m),
NewMargin(interestDirection: (int)SwapDirectionEnum., interestIncomeSum: 600m)
};
var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins);
Assert.AreEqual(400m, result, 0.0001m, "双腿轧差:1000 - 600 = 400");
}
// ================================================================
// 场景5:本金为零但 InterestIncomeSum 非零 —— 回归旧 bug 关键场景
// 旧实现:totalWeight=0 → 返回 0,丢失利息
// 新实现:不看本金,按方向轧差 InterestIncomeSum
// ================================================================
[TestMethod]
public void _利息仍按方向轧差_不丢失()
{
var margins = new[]
{
NewMargin(interestDirection: (int)SwapDirectionEnum.,
interestIncomeSum: 500m, interestPrincipalFix: 0m),
NewMargin(interestDirection: (int)SwapDirectionEnum.,
interestIncomeSum: 200m, interestPrincipalFix: 0m)
};
var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins);
Assert.AreEqual(300m, result, 0.0001m,
"本金为零时旧实现返回 0 丢失利息,新实现应按方向轧差 = 500 - 200 = 300");
}
// ================================================================
// 场景6:多腿混合方向 —— 收取 100+200,支付 50+80 → 170
// ================================================================
[TestMethod]
public void _正确轧差()
{
var margins = new[]
{
NewMargin(interestDirection: (int)SwapDirectionEnum., interestIncomeSum: 100m),
NewMargin(interestDirection: (int)SwapDirectionEnum., interestIncomeSum: 50m),
NewMargin(interestDirection: (int)SwapDirectionEnum., interestIncomeSum: 200m),
NewMargin(interestDirection: (int)SwapDirectionEnum., interestIncomeSum: 80m)
};
var result = SwapEodPositionService.CalculateWeightedMarginInterest(margins);
Assert.AreEqual(170m, result, 0.0001m, "多腿轧差:(100+200) - (50+80) = 170");
}
// ================================================================
// 场景7:null 入参防御 —— Sum 对 null 抛 ArgumentNullException
// 仅作行为锚点:若未来改为 null 安全,此处需同步更新
// ================================================================
[TestMethod]
public void Null入参_抛ArgumentNullException()
{
Assert.ThrowsException<ArgumentNullException>(() =>
SwapEodPositionService.CalculateWeightedMarginInterest(null!));
}
// ================================================================
// Helper:构造 eod_swap_position(只设置参与计算的 2 个字段 + 可选本金)
// ================================================================
private static eod_swap_position NewMargin(
int interestDirection,
decimal interestIncomeSum,
decimal interestPrincipalFix = 0m)
{
return new eod_swap_position
{
InterestDirection = interestDirection,
InterestIncomeSum = interestIncomeSum,
InterestPrincipalFix = interestPrincipalFix
};
}
}
}