Files
zszq-trs/UnitTestProject/Modules/SwapModule/MultiUnwindDividendConservationTest.cs
T
hjhan 7b007bddfa refactor(swap-test): 抽取 TestableSwapEodPositionService 公共基类收敛重复 Stub
- 新建 TestableSwapEodPositionService 收敛 8/8 Stub 重复的高频 override
  (PersistEodSwapPosition/SaveAllChanges/GetCurrencyRate/AddClientCash)
  + 统一 OptUserInfo 构造 + PersistedPositions/SaveChangesCount 输出捕获
- SwapEodPositionService.DealInterests 改 protected virtual(行为零变化)
- 8 个 ScenarioTest 改为继承基类,删除重复 override
- 消灭 DealInterestsScenarioTest/DealInterestsGoldenReplayTest 的反射调用
  (typeof().GetMethod().Invoke → 直接调用 DealInterests)

验证:dotnet build 0 错误;dotnet test SwapModule 284通过/6跳过/0失败
2026-07-23 11:16:11 +08:00

319 lines
19 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 YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 期间多次部分平仓 + 中间穿插互换 —— PosiDividendSum 递推一致性测试
/// ============================================================================
/// 背景:GLMS-20260105-0007 修复(SwapEodPositionService.cs:1627-1636)把
/// UpdateEodPosition 的 PosiDividendSum 从"从头重算"改为递增模式:
/// PosiDividendSum = 前日 PosiDividendSum + 当日 TdPosiDividend - 当日 TdCloseDividend
///
/// 该递推公式此前只在 GLMS20260105GoldenTest 的"单日全互换"场景被验证过(持仓恒定、
/// 1 条互换事件、全部结清)。本测试补齐覆盖空白:
/// - 跨多个结算日的逐日递推一致性
/// - 部分平仓后,当日新增分红 TdPosiDividend 按【剩余持仓】计算(cs:1614 用 curretEod.PosiQuantity
/// - 中间穿插互换结算(EventType=3,不扣持仓,但 DividendIn 进入 TdCloseDividend
/// - 全平后 PosiQuantity==0 走 else 分支 PosiDividendSum=0
/// - 守恒:累计 RealizedDividend + 末尾待实现 ≈ 全程 TdPosiDividend 总和(round 累积容差内)
/// - 无从头重算的精度漂移
///
/// 内存 stub,无数据库依赖,进 CI。
/// ============================================================================
[TestClass]
public class MultiUnwindDividendConservationTest
{
// 场景常量:收取方向(directionRatio=+1,数值为正便于手算)、多头、1000 单位持仓。
// 注:GLMS20260105 真实数据为支付方向(数值为负),但递推公式与方向无关,
// 本测试取收取方向让期望值直观易读。
private const int SwapTradeId = 9100;
private const long PositionId = 9101;
private const decimal InitialQty = 1000m;
private const decimal DailyRatePerUnit = 0.01m; // 每单位每天票息 0.01,便于手算
private static readonly DateTime StartDate = new(2026, 1, 5);
#region Stub
/// <summary>
/// 参考 DealFloatPositionsScenarioTest.StubEodService,关键改进:
/// CalcBondPayment 改为按天数 × 持仓线性函数,使 TdPosiDividend 真实随
/// "天数 × 剩余持仓"变化——这是验证多日递推守恒的前提。
/// </summary>
private sealed class StubEodService : TestableSwapEodPositionService
{
private readonly decimal _dailyRatePerUnit;
public StubEodService(decimal dailyRatePerUnit) : base(nameof(MultiUnwindDividendConservationTest))
{
_dailyRatePerUnit = dailyRatePerUnit;
}
// 按天线性付息:dailyRate × 天数 × 持仓 × shortRatio × directionRatio
// 与生产 BondPaymentService 的线性口径一致,便于手算期望值
protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio)
{
int days = Math.Max(0, (int)(toDate - fromDate).TotalDays);
return _dailyRatePerUnit * days * qty * shortRatio * directionRatio;
}
protected override underlying_manager GetUnderlyingData(string underlyingCode)
=> new underlying_manager { ValueAddedTax = 0m };
protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp)
{
vobp = 0m; // vobp=0 让 Dv01Helper.CalcDv01 短路返回 0,不触碰 DataCacheProvider
return 1.00m;
}
// 暴露 protected UpdateEodPosition(参考 GLMS20260105GoldenTest.ReplayStubService:244
public eod_swap_position ExecuteUpdateEodPosition(
swap_position swapPosition, eod_swap_position eod, trade td,
DateTime valueDate, DateTime preSettleDate, List<swap_flow_event> unwindEvents)
{
return UpdateEodPosition(swapPosition, eod, null, td, valueDate, preSettleDate, unwindEvents);
}
// 暴露 protected CopyEodPosition(无事件日用,与 DealFloatPositions 的真实分派一致)
public eod_swap_position ExecuteCopyEodPosition(eod_swap_position eod, trade td, DateTime valueDate, DateTime preSettleDate)
{
return CopyEodPosition(eod, null, td, valueDate, preSettleDate);
}
}
#endregion
#region 数据构建
private static trade CreateTrade()
{
return new trade
{
id = SwapTradeId, TradeNumber = "UT-MULTI-UNWIND-001", ClientId = 999999,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = new DateTime(2027, 1, 5), TradeStatus = "确认成交",
ValidState = "Valid", StructureType = "单标的",
QuoteCurrency = "CNY", SettlementCurrency = "CNY", // 同币种:汇率短路 Rate=1,不查库
OriginalStockEqvNotional = (double)(InitialQty * 1.00m) // 与持仓 × 净价匹配
};
}
private static swap_position CreatePosition()
{
return new swap_position
{
id = PositionId, SwapTradeId = SwapTradeId,
PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long,
UnderlyingCode = "210210.IB", ContractSize = 1m,
PosiQuantity = InitialQty, PosiNotionalValue = InitialQty,
PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m,
PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m,
IsInitial = true, Invalid = false,
PosiTradingFee = 0, PosiTradingFeePending = 0
};
}
/// <summary>首日 eodValueDate=StartDatePosiDividendSum=0</summary>
private static eod_swap_position CreateInitialEod()
{
return new eod_swap_position
{
id = 1, SwapTradeId = SwapTradeId, PositionId = PositionId,
ValueDate = StartDate, PosiQuantity = InitialQty,
PosiDirection = (int)SwapDirectionEnum.收取, PositionType = (int)PositionTypeFlag.Long,
UnderlyingCode = "210210.IB", ContractSize = 1m,
PosiNetPrice = 1.000m, PosiGrossPrice = 1.000m,
PosiNetFeePrice = 1.000m, PosiNetNoFeePrice = 1.000m,
PosiDividendSum = 0m, TdPosiDividend = 0m, TdCloseDividend = 0m,
RealizedDividend = 0m, PosiFeePending = 0m,
InterestProfitSum = 0m, Invalid = false
};
}
private static swap_flow_event CloseEvent(decimal qty, decimal dividendIn, DateTime eventDate)
{
return new swap_flow_event
{
SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.平仓,
PositionId = PositionId, Quantity = qty, DividendIn = dividendIn,
MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m,
TradingAmount = qty * 1.000m,
UnwindDate = eventDate, EventDate = eventDate, PayDate = eventDate,
DataState = (int)SwapFlowDateStateEnum.完成
};
}
private static swap_flow_event SwapEvent(decimal dividendIn, DateTime eventDate)
{
return new swap_flow_event
{
SwapTradeId = SwapTradeId, EventType = (int)SwapFlowEventTypeEnum.互换,
PositionId = PositionId, Quantity = 0m, DividendIn = dividendIn,
MarkClosePnl = 0m, CloseFee = 0m, TradingFeePending = 0m,
EventDate = eventDate, PayDate = eventDate,
DataState = (int)SwapFlowDateStateEnum.完成
};
}
#endregion
// ================================================================
// 主测试:6 个结算日的多次部分平仓 + 中间互换序列
// ================================================================
/// <summary>
/// [MU_001] 期间多次部分平仓 + 中间穿插互换 → PosiDividendSum 逐日递推一致、全平归零、守恒
/// ----------------------------------------------------------------------------
/// 真实 EOD 分派(DealFloatPositions:496-507):无事件走 CopyEodPosition
/// 有平仓/互换事件走 UpdateEodPosition。本测试按此分派编排 6 个结算日,
/// 验证两个方法的 PosiDividendSum 递推口径在跨方法、多事件下严格对齐
/// (这正是 GLMS-20260105-0007 修复的核心:两者口径一致才能消除漂移)。
///
/// 序列(trade.StartDate=2026-01-05,每日间隔1天,DailyRatePerUnit=0.01):
/// D1=01-06 无事件(Copy) :增量10(1天×0.01×1000), Sum: 0→10
/// D2=01-07 部分平仓30% :持仓1000→700, 增量7(按700), 实现3, Sum: 10→14
/// D3=01-08 无事件(Copy) :增量7(按700), Sum: 14→21
/// D4=01-09 互换结算 EventType=3不扣持仓=700, 增量7, 实现10, Sum: 21→18
/// D5=01-10 无事件(Copy) :增量7(按700), Sum: 18→25
/// D6=01-11 全平700 :持仓→0, 增量0(全平后不计), Sum: 0(else分支)
/// </summary>
[TestMethod]
public void MU_001_多次部分平仓穿插互换_分红递推一致且守恒()
{
var service = new StubEodService(DailyRatePerUnit);
var td = CreateTrade();
var position = CreatePosition();
// 记录全程累积量,供末尾守恒断言
decimal sumTdPosiDividend = 0m; // 全程新增待实现分红之和
decimal sumTdCloseDividend = 0m; // 全程已实现分红之和
var dailyResults = new List<(DateTime date, eod_swap_position eod)>();
var initialEod = CreateInitialEod();
// ---- D1=2026-01-06:无事件 → CopyEodPosition 分支 ----
var d1 = new DateTime(2026, 1, 6);
var r1 = service.ExecuteCopyEodPosition(initialEod, td, d1, StartDate);
dailyResults.Add((d1, r1));
sumTdPosiDividend += r1.TdPosiDividend;
// 期望:TdPosiDividend = 0.01 × 1天 × 1000 = 10PosiDividendSum = 0 + 10 = 10
AssertDecimalEqual(10m, r1.TdPosiDividend, 0.01m, "D1 TdPosiDividend(1天×0.01×1000)");
AssertDecimalEqual(10m, r1.PosiDividendSum, 0.01m, "D1 PosiDividendSum(0+10)");
Assert.AreEqual(InitialQty, r1.PosiQuantity, "D1 持仓不变");
AssertCopyIncrementalConsistency(initialEod, r1, "D1");
// ---- D2=2026-01-07:部分平仓 30%300单位)→ UpdateEodPosition 分支 ----
var d2 = new DateTime(2026, 1, 7);
var d2DividendIn = 3m; // 平仓实现的分红(业务方按平仓比例给定)
var r2 = service.ExecuteUpdateEodPosition(position, r1, td, d2, d1, new List<swap_flow_event> { CloseEvent(300m, d2DividendIn, d2) });
dailyResults.Add((d2, r2));
sumTdPosiDividend += r2.TdPosiDividend;
sumTdCloseDividend += r2.TdCloseDividend;
Assert.AreEqual(700m, r2.PosiQuantity, "D2 部分平仓后持仓 1000-300=700");
// 【关键】TdPosiDividend 按【剩余持仓 700】算:0.01 × 1天 × 700 = 7(不是 10
AssertDecimalEqual(7m, r2.TdPosiDividend, 0.01m, "D2 TdPosiDividend 应按剩余持仓700算(1天×0.01×700)");
AssertDecimalEqual(d2DividendIn, r2.TdCloseDividend, 0.01m, "D2 TdCloseDividend=平仓DividendIn");
// 递推:PosiDividendSum = 10 + 7 - 3 = 14
AssertDecimalEqual(14m, r2.PosiDividendSum, 0.01m, "D2 PosiDividendSum(10+7-3)");
AssertUpdateIncrementalConsistency(r1, r2, "D2");
// ---- D3=2026-01-08:无事件 → CopyEodPosition(继续按 700 累积)----
var d3 = new DateTime(2026, 1, 8);
var r3 = service.ExecuteCopyEodPosition(r2, td, d3, d2);
dailyResults.Add((d3, r3));
sumTdPosiDividend += r3.TdPosiDividend;
Assert.AreEqual(700m, r3.PosiQuantity, "D3 持仓不变=700Copy继承前日)");
AssertDecimalEqual(7m, r3.TdPosiDividend, 0.01m, "D3 TdPosiDividend(1天×0.01×700)");
AssertDecimalEqual(21m, r3.PosiDividendSum, 0.01m, "D3 PosiDividendSum(14+7)");
AssertCopyIncrementalConsistency(r2, r3, "D3");
// ---- D4=2026-01-09:互换结算(EventType=3,不扣持仓,DividendIn 进入 TdCloseDividend)→ Update 分支 ----
var d4 = new DateTime(2026, 1, 9);
var d4DividendIn = 10m; // 互换事件实现的待实现分红
var r4 = service.ExecuteUpdateEodPosition(position, r3, td, d4, d3, new List<swap_flow_event> { SwapEvent(d4DividendIn, d4) });
dailyResults.Add((d4, r4));
sumTdPosiDividend += r4.TdPosiDividend;
sumTdCloseDividend += r4.TdCloseDividend;
Assert.AreEqual(700m, r4.PosiQuantity, "D4 互换事件不扣持仓=700");
AssertDecimalEqual(7m, r4.TdPosiDividend, 0.01m, "D4 TdPosiDividend(互换不影响增量,1天×0.01×700)");
AssertDecimalEqual(d4DividendIn, r4.TdCloseDividend, 0.01m, "D4 TdCloseDividend=互换DividendIn");
// 递推:PosiDividendSum = 21 + 7 - 10 = 18
AssertDecimalEqual(18m, r4.PosiDividendSum, 0.01m, "D4 PosiDividendSum(21+7-10)");
AssertUpdateIncrementalConsistency(r3, r4, "D4");
// ---- D5=2026-01-10:无事件 → CopyEodPosition ----
var d5 = new DateTime(2026, 1, 10);
var r5 = service.ExecuteCopyEodPosition(r4, td, d5, d4);
dailyResults.Add((d5, r5));
sumTdPosiDividend += r5.TdPosiDividend;
Assert.AreEqual(700m, r5.PosiQuantity, "D5 持仓=700");
AssertDecimalEqual(7m, r5.TdPosiDividend, 0.01m, "D5 TdPosiDividend");
AssertDecimalEqual(25m, r5.PosiDividendSum, 0.01m, "D5 PosiDividendSum(18+7)");
AssertCopyIncrementalConsistency(r4, r5, "D5");
// ---- D6=2026-01-11:全平 700 → Update 分支 ----
var d6 = new DateTime(2026, 1, 11);
var d6DividendIn = 25m; // 把剩余待实现全作 DividendIn 实现
var r6 = service.ExecuteUpdateEodPosition(position, r5, td, d6, d5, new List<swap_flow_event> { CloseEvent(700m, d6DividendIn, d6) });
dailyResults.Add((d6, r6));
sumTdPosiDividend += r6.TdPosiDividend;
sumTdCloseDividend += r6.TdCloseDividend;
Assert.AreEqual(0m, r6.PosiQuantity, "D6 全平后持仓=0");
// 全平走 else 分支:PosiDividendSum=0(不再递推)
AssertDecimalEqual(0m, r6.PosiDividendSum, 0.01m, "D6 全平后 PosiDividendSum=0(else分支)");
Assert.AreEqual(1, r6.PosiStatus, "D6 PosiStatus=已平");
// ================================================================
// 守恒性断言:全程新增 - 全程实现 = 末尾待实现(容差 0.01 允许 round 累积)
// ================================================================
decimal endingPending = sumTdPosiDividend - sumTdCloseDividend;
AssertDecimalEqual(r6.PosiDividendSum, endingPending, 0.01m,
$"守恒:末尾 PosiDividendSum({r6.PosiDividendSum}) 应=全程新增({sumTdPosiDividend})-全程实现({sumTdCloseDividend})={endingPending}");
Console.WriteLine("=== 多日序列 PosiDividendSum 演变 ===");
foreach (var (date, eod) in dailyResults)
{
Console.WriteLine($" {date:yyyy-MM-dd}: Qty={eod.PosiQuantity}, TdPosiDividend={eod.TdPosiDividend}, TdCloseDividend={eod.TdCloseDividend}, PosiDividendSum={eod.PosiDividendSum}, RealizedDividend={eod.RealizedDividend}");
}
Console.WriteLine($"\n守恒检查:Σ新增={sumTdPosiDividend}, Σ实现={sumTdCloseDividend}, 末尾待实现={r6.PosiDividendSum} ✅");
}
// ================================================================
// 辅助断言:逐日递推一致性(持仓>0 时)
// ================================================================
/// <summary>
/// 验证 UpdateEodPosition 的 PosiDividendSum 严格满足递推公式(cs:1631):
/// PosiDividendSum = prev.PosiDividendSum + result.TdPosiDividend - result.TdCloseDividend
/// (仅持仓>0 时适用;全平时走 else 归零,由调用方单独断言)
/// </summary>
private static void AssertUpdateIncrementalConsistency(eod_swap_position prev, eod_swap_position result, string day)
{
if (result.PosiQuantity <= 0) return; // 全平走 else 分支,跳过递推断言
decimal expected = prev.PosiDividendSum + result.TdPosiDividend - result.TdCloseDividend;
Assert.IsTrue(Math.Abs(expected - result.PosiDividendSum) <= 0.01m,
$"{day} Update递推一致性失败:期望 PosiDividendSum={prev.PosiDividendSum}+{result.TdPosiDividend}-{result.TdCloseDividend}={expected},实际={result.PosiDividendSum}");
}
/// <summary>
/// 验证 CopyEodPosition 的 PosiDividendSum 严格满足递推公式(cs:1527):
/// PosiDividendSum = Math.Round(prev.PosiDividendSum + result.TdPosiDividend, 2)
/// (无平仓日,TdCloseDividend=0;仅持仓>0 时适用)
/// </summary>
private static void AssertCopyIncrementalConsistency(eod_swap_position prev, eod_swap_position result, string day)
{
if (result.PosiQuantity <= 0) return;
decimal expected = Math.Round(prev.PosiDividendSum + result.TdPosiDividend, 2);
Assert.IsTrue(Math.Abs(expected - result.PosiDividendSum) <= 0.01m,
$"{day} Copy递推一致性失败:期望 PosiDividendSum=Round({prev.PosiDividendSum}+{result.TdPosiDividend},2)={expected},实际={result.PosiDividendSum}");
// Copy 分支当日无实现
Assert.AreEqual(0m, result.TdCloseDividend, $"{day} Copy分支应无 TdCloseDividend");
}
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}");
}
}
}