日期 事件 期望行为
D1=2026-01-06 无事件(Copy 分支) TdPosiDividend=1天增量,PosiDividendSum=增量 D2=2026-01-07 部分平仓 30%(EventType=2, Quantity=300, DividendIn=按比例) PosiQuantity→700,TdPosiDividend按(700)算,PosiDividendSum递推 D3=2026-01-08 无事件 增量按剩余700算 D4=2026-01-09 互换结算(EventType=3, DividendIn=部分待实现金额,不扣持仓) PosiQuantity不变=700,TdCloseDividend=互换DividendIn,PosiDividendSum递推 D5=2026-01-10 无事件 继续按700累积 D6=2026-01-11 全平(EventType=2, Quantity=700, 全部待实现作DividendIn) PosiQuantity→0,PosiDividendSum=0(else分支)
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
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 : SwapEodPositionService
|
||||
{
|
||||
private readonly decimal _dailyRatePerUnit;
|
||||
|
||||
public StubEodService(decimal dailyRatePerUnit) : base(new OptUserInfo(0, nameof(MultiUnwindDividendConservationTest), OptUserFrom.UnitTest))
|
||||
{
|
||||
_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 override void SaveAllChanges() { }
|
||||
|
||||
// 注意:UpdateEodPosition.cs:1645 直接 new EodCurrencyRateService,不走此 seam;
|
||||
// 但 trade.QuoteCurrency == trade.SettlementCurrency == "CNY" 时,
|
||||
// EodCurrencyRateService.GetEodCurrencyRate 会在查库前短路返回 Rate=1(cs:268-281)
|
||||
protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType)
|
||||
=> 1.0;
|
||||
|
||||
// 暴露 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>首日 eod:ValueDate=StartDate,PosiDividendSum=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 = 10;PosiDividendSum = 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 持仓不变=700(Copy继承前日)");
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user