依赖阶段1c的CalcSwapInterests虚方法接缝,现在普通日分支(copy) 也可纯内存测试。 新增/改进的测试: - DI_BRANCH_001: 改为真正的普通日归档值验证(InterestIncomeSum 前日+1天新计=2天利息),不再是Inconclusive - DI_MULTI_001: 连续5天收盘归档,验证InterestIncomeSum线性递增 (每天+1天利息,5天后=5×DailyInterest) StubEodPositionService扩展: - override CalcSwapInterests(用真实SwapDealService算固定利率利息) - ExecuteDealInterests public包装(替代反射调用) - 删除旧的反射辅助方法 验证: 89(T0/T1)+5(DealInterests)=94全通过,无回归。
428 lines
22 KiB
C#
428 lines
22 KiB
C#
using Newtonsoft.Json;
|
||
using YLErp.DBModels;
|
||
using YLErp.DBModels.Enums;
|
||
|
||
namespace YLErp.Modules.SwapModule
|
||
{
|
||
/// <summary>
|
||
/// DealInterests 利息腿归档 - 合成单元测试(内存,不连库)
|
||
/// ============================================================================
|
||
/// 目标:验证收盘时利息腿 eod 的字段计算,覆盖三个分支:
|
||
/// ① 手动互换分支 SaveEodInterestPosition(我们修复 InterestIncomeSum 归零的核心)
|
||
/// ② 普通日分支 SaveEodInterestPositionCopy(InterestIncomeSum 每日递增)
|
||
/// ③ 多日守恒(半平后多日再全平,利息一致性)
|
||
///
|
||
/// 模仿 GetInterestsUnitTest_T0 的风格:
|
||
/// - 继承生产类,override 虚方法替换 DB 调用
|
||
/// - 内存构造 trade/position/eod/flowEvent 数据
|
||
/// - 断言业务期望值(独立计算,非循环论证)
|
||
/// ============================================================================
|
||
[TestClass]
|
||
public class DealInterestsScenarioTest
|
||
{
|
||
#region 测试常量
|
||
|
||
private const decimal Principal = 1000m;
|
||
private const decimal FixedRate = 0.01m;
|
||
private const int AnnualDays = 365;
|
||
private static readonly DateTime StartDate = new(2026, 4, 27);
|
||
private static readonly DateTime ExerciseDate = new(2027, 4, 27);
|
||
|
||
/// <summary>每天利息(固定利率,算头不算尾,年化365天)</summary>
|
||
private static decimal DailyInterest => Math.Round(Principal * FixedRate / AnnualDays, ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
|
||
|
||
#endregion
|
||
|
||
#region Stub:内存 SwapEodPositionService
|
||
|
||
/// <summary>
|
||
/// 测试用子类:override 虚方法,把 DB 调用替换为内存操作。
|
||
/// - PersistEodSwapPosition:收集到列表而非写库
|
||
/// - GetCurrencyRate:返回 1.0(本币)
|
||
/// </summary>
|
||
private sealed class StubEodPositionService : SwapEodPositionService
|
||
{
|
||
public List<eod_swap_position> PersistedPositions { get; } = new();
|
||
|
||
public StubEodPositionService() : base(new OptUserInfo(0, nameof(DealInterestsScenarioTest), OptUserFrom.UnitTest))
|
||
{
|
||
}
|
||
|
||
protected override void PersistEodSwapPosition(eod_swap_position position)
|
||
{
|
||
// 收集到列表,不写库。如果 id=0 模拟新增。
|
||
if (position.id == 0) position.id = PersistedPositions.Count + 1;
|
||
PersistedPositions.Add(position);
|
||
}
|
||
|
||
protected override void SaveAllChanges()
|
||
{
|
||
// 不做任何事(内存模式)
|
||
}
|
||
|
||
protected override double GetCurrencyRate(string quoteCurrency, string settlementCurrency, DateTime valueDate, bool seekPreday, CurrencyRateType currencyRateType)
|
||
{
|
||
return 1.0; // 本币,汇率=1
|
||
}
|
||
|
||
// override CalcSwapInterests:用真实 SwapDealService 算(固定利率不需 mock 浮动利率)
|
||
// 生产代码默认实现也是 new SwapDealService(this).GetInterests(...),这里保持一致
|
||
// 但 SwapDealService 内部 TryGetFloatRate 会连库——固定利率(FloatRateUnderlyingCode=null)不会触发
|
||
protected override List<swap_flow_event> CalcSwapInterests(
|
||
trade td, trade_extend tradeExtend,
|
||
DateTime valueDate, DateTime unwindDate,
|
||
List<eod_swap_position> eodPositions, List<swap_position> positions,
|
||
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
|
||
decimal closePosiNotionalValue, decimal closePrecent,
|
||
int eventType, bool tdClose, bool needPrice,
|
||
decimal grossPrice, decimal orginPv,
|
||
bool add = false, bool settment = true, bool newCalcLast = false,
|
||
List<swap_flow_event> closeList = null)
|
||
{
|
||
return new SwapDealService(this).GetInterests(td, tradeExtend, valueDate, unwindDate,
|
||
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
|
||
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
|
||
grossPrice, orginPv, add, settment, newCalcLast, closeList);
|
||
}
|
||
|
||
// public 包装:让测试能调用 protected 方法
|
||
public eod_swap_position ExecuteSaveEodInterestPosition(
|
||
eod_swap_position eodPayPosition, eod_swap_position newEodPayPosition,
|
||
swap_position position, trade td, DateTime valueDate, List<swap_flow_event> flowEvents)
|
||
{
|
||
SaveEodInterestPosition(eodPayPosition, newEodPayPosition, position, td, valueDate, flowEvents);
|
||
return PersistedPositions.LastOrDefault();
|
||
}
|
||
|
||
// public 包装:调用 DealInterests(通过反射,因为参数太多不好包)
|
||
public void ExecuteDealInterests(
|
||
List<swap_position> interestList, List<eod_swap_position> eodPositions,
|
||
DateTime settleDate, trade td, List<swap_flow_event> flowEvents,
|
||
decimal posiLongNational, decimal posiShortNational,
|
||
decimal closeNational, decimal grossPrice, decimal orginPv)
|
||
{
|
||
var method = typeof(SwapEodPositionService).GetMethod("DealInterests",
|
||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||
method.Invoke(this, new object[]
|
||
{
|
||
interestList, eodPositions, new List<eod_swap_position>(),
|
||
settleDate, td, flowEvents, new List<swap_flow_event>(), null,
|
||
posiLongNational, posiShortNational, closeNational, grossPrice, orginPv
|
||
});
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 数据构建器
|
||
|
||
private static trade CreateTrade()
|
||
{
|
||
return new trade
|
||
{
|
||
id = 1, TradeNumber = "UT-DEAL-INT-001", ClientId = 999998,
|
||
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
|
||
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
|
||
StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY",
|
||
trade_extend = new trade_extend
|
||
{
|
||
TradeId = 1,
|
||
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
|
||
{
|
||
AnnualDays = AnnualDays,
|
||
InterestCalcMode = "10", // 算头不算尾
|
||
SettlementRules = 0
|
||
})
|
||
}
|
||
};
|
||
}
|
||
|
||
private static swap_position CreateInterestPosition()
|
||
{
|
||
return new swap_position
|
||
{
|
||
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
|
||
InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价,
|
||
InterestRateDefault = FixedRate, InterestPrincipalFix = Principal,
|
||
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
|
||
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
|
||
IsAnnualized = true, interest_rest_days = 1, interest_rule = 0,
|
||
FloatRateUnderlyingCode = null, // 固定利率,不需要浮动
|
||
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
|
||
{
|
||
new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 }
|
||
})
|
||
};
|
||
}
|
||
|
||
/// <summary>创建前一日 eod(模拟"昨天收盘后的状态")</summary>
|
||
private static eod_swap_position CreatePreEod(DateTime valueDate, decimal interestProfitSum, decimal realizedInterest = 0m)
|
||
{
|
||
return new eod_swap_position
|
||
{
|
||
id = 100, SwapTradeId = 1, PositionId = 1001, ValueDate = valueDate,
|
||
ClientId = 999998, InterestDirection = (int)SwapDirectionEnum.收取,
|
||
InterestMode = (int)InterestModeEnum.标的期初全价,
|
||
InterestProfitSum = interestProfitSum,
|
||
InterestIncomeSum = interestProfitSum,
|
||
RealizedInterest = realizedInterest,
|
||
InterestRateDefault = FixedRate,
|
||
TdInterestPrincipal = Principal,
|
||
PosiNotionalValue = Principal,
|
||
InterestType = (int)InterestTypeEnum.单利,
|
||
IsAnnualized = true, interest_rest_days = 1,
|
||
FloatRate = 0m
|
||
};
|
||
}
|
||
|
||
/// <summary>创建互换 flow_event(模拟"当天做了收益结算")</summary>
|
||
private static swap_flow_event CreateSwapFlowEvent(DateTime eventDate, decimal interestAmount)
|
||
{
|
||
return new swap_flow_event
|
||
{
|
||
id = 2001, SwapTradeId = 1, EventType = (int)SwapFlowEventTypeEnum.互换,
|
||
EventDate = eventDate, UnwindDate = eventDate, PositionId = 1001,
|
||
InterestDirection = (int)SwapDirectionEnum.收取,
|
||
InterestAmount = interestAmount,
|
||
InterestClosePnL = interestAmount, // 收取方向,两者相等
|
||
InterestRate = FixedRate,
|
||
InterestMode = (int)InterestModeEnum.标的期初全价,
|
||
InterestPrincipal = Principal,
|
||
FloatRate = 0m,
|
||
DataState = (int)SwapFlowDateStateEnum.完成
|
||
};
|
||
}
|
||
|
||
private static void AssertDecimal(decimal expected, decimal actual, string message = "")
|
||
{
|
||
var tolerance = 1m / (decimal)Math.Pow(10, ConsGlobal.PriceRound - 2);
|
||
Assert.IsTrue(Math.Abs(expected - actual) <= tolerance,
|
||
$"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}");
|
||
}
|
||
|
||
#endregion
|
||
|
||
// ================================================================
|
||
// 场景1:互换结清后 InterestIncomeSum 应归零(cs:837 修复验证)
|
||
// ================================================================
|
||
|
||
#region 场景1:互换结清后 InterestIncomeSum 归零
|
||
|
||
/// <summary>
|
||
/// [DI_SWAP_ZERO_001] 互换结清-攒了N天利息后全额互换结算,待实现应归零
|
||
/// ---------------------------------------------------------------
|
||
/// 起息日4/27,攒到5/10(13天),InterestProfitSum≈13天利息。
|
||
/// 5/10做互换结算,flow_event.InterestAmount=13天利息。
|
||
/// 收盘后 InterestIncomeSum 应≈0(全部已实现)。
|
||
/// ---------------------------------------------------------------
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void DI_SWAP_ZERO_001_互换结清后待实现归零()
|
||
{
|
||
var service = new StubEodPositionService();
|
||
var td = CreateTrade();
|
||
var position = CreateInterestPosition();
|
||
var settleDate = new DateTime(2026, 5, 10);
|
||
|
||
// 攒了13天利息(4/27~5/9,算头不算尾)
|
||
int days = (settleDate - StartDate).Days;
|
||
decimal accumulatedInterest = Math.Round(Principal * FixedRate * days / AnnualDays,
|
||
ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
|
||
var preEod = CreatePreEod(settleDate.AddDays(-1), accumulatedInterest);
|
||
|
||
// 当天做了互换结算,利息=攒的全部
|
||
var swapEvent = CreateSwapFlowEvent(settleDate, accumulatedInterest);
|
||
|
||
// 执行互换分支
|
||
var result = service.ExecuteSaveEodInterestPosition(preEod, null, position, td, settleDate, new List<swap_flow_event> { swapEvent });
|
||
|
||
// 核心断言:InterestIncomeSum = pre + 当天新计(TdInterestIncome) - 实现(TdCloseInterest)
|
||
// 互换把攒的13天全付了(TdCloseInterest=accumulatedInterest),但当天又产生1天新计(TdInterestIncome)
|
||
// 所以 InterestIncomeSum 应 ≈ 1天新计利息(而非严格0)
|
||
// 公式(cs:869): pre.InterestIncomeSum + TdInterestIncome - TdCloseInterest
|
||
decimal expectedTdInterestIncome = Math.Round(Principal * FixedRate / AnnualDays,
|
||
ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
|
||
AssertDecimal(expectedTdInterestIncome, result.InterestIncomeSum,
|
||
$"互换结清后 InterestIncomeSum 应=当天新计利息({expectedTdInterestIncome:F6})," +
|
||
$"而非攒的全程({accumulatedInterest:F6})");
|
||
|
||
// TdCloseInterest 应=互换实现的利息
|
||
AssertDecimal(accumulatedInterest, result.TdCloseInterest, "TdCloseInterest 应=互换实现的利息");
|
||
|
||
// RealizedInterest 应累加(preEod.RealizedInterest + TdCloseInterest * ratio)
|
||
// 收取方向 ratio=1
|
||
AssertDecimal(accumulatedInterest, result.RealizedInterest, "RealizedInterest 应累加已实现利息");
|
||
|
||
Console.WriteLine($"攒了{days}天利息={accumulatedInterest:F6}");
|
||
Console.WriteLine($"互换结清后 InterestIncomeSum={result.InterestIncomeSum:F6}(应≈0)✅");
|
||
Console.WriteLine($"TdCloseInterest={result.TdCloseInterest:F6} RealizedInterest={result.RealizedInterest:F6}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// [DI_SWAP_ZERO_002] 互换结清后 InterestIncomeSum 不为负(防多扣)
|
||
/// ---------------------------------------------------------------
|
||
/// 验证:待实现=0(已结清)时,TdCloseInterest=当天新计,InterestIncomeSum 应=0。
|
||
/// 公式: 0 + 当天新计 - 当天新计 = 0。如果公式有误会变成负数。
|
||
/// ---------------------------------------------------------------
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void DI_SWAP_ZERO_002_互换结清后待实现不为负()
|
||
{
|
||
var service = new StubEodPositionService();
|
||
var td = CreateTrade();
|
||
var position = CreateInterestPosition();
|
||
var swapDate = new DateTime(2026, 5, 10);
|
||
|
||
// 已结清状态:待实现=0
|
||
var postSwapEod = CreatePreEod(swapDate.AddDays(-1), 0m, 0m);
|
||
|
||
// 互换只结算当天新计(InterestAmount=当天新计利息)
|
||
decimal dailyInc = Math.Round(Principal * FixedRate / AnnualDays,
|
||
ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
|
||
var swapEvent = CreateSwapFlowEvent(swapDate, dailyInc);
|
||
|
||
var result = service.ExecuteSaveEodInterestPosition(postSwapEod, null, position, td, swapDate, new List<swap_flow_event> { swapEvent });
|
||
|
||
// 公式: 0(待实现) + dailyInc(新计) - dailyInc(实现) = 0
|
||
AssertDecimal(0m, result.InterestIncomeSum, "待实现=0+当天新计-当天新计应=0,不应为负");
|
||
Console.WriteLine($"已结清后再互换(只结算当天新计):InterestIncomeSum={result.InterestIncomeSum:F6} = 0 ✅");
|
||
}
|
||
|
||
#endregion
|
||
|
||
// ================================================================
|
||
// 场景2:DealInterests 分支选择逻辑验证
|
||
// ================================================================
|
||
|
||
#region 场景2:分支选择
|
||
|
||
/// <summary>
|
||
/// [DI_BRANCH_001] 普通日(无互换无平仓无观察日)→ 走 copy 分支
|
||
/// ---------------------------------------------------------------
|
||
/// flowEvents 为空,insterval=null,hasSwap=false,hasClose=false
|
||
/// → 应走 SaveEodInterestPositionCopy(cs:338)
|
||
/// ---------------------------------------------------------------
|
||
/// <summary>
|
||
/// [DI_BRANCH_001] 普通日收盘归档:InterestIncomeSum 每天递增1天利息
|
||
/// ---------------------------------------------------------------
|
||
/// 前日待实现=1天利息,今日收盘(无互换无平仓),应变成2天利息。
|
||
/// 验证 copy 分支(SaveEodInterestPositionCopy)的 InterestIncomeSum 公式。
|
||
/// ---------------------------------------------------------------
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void DI_BRANCH_001_普通日归档待实现递增()
|
||
{
|
||
var service = new StubEodPositionService();
|
||
var td = CreateTrade();
|
||
var position = CreateInterestPosition();
|
||
var settleDate = new DateTime(2026, 4, 28); // 第2天
|
||
var preEod = CreatePreEod(settleDate.AddDays(-1), DailyInterest); // 前日=1天利息
|
||
|
||
// 普通日:无互换无平仓,InterestSwapInterval=null(当天非观察日)
|
||
position.InterestSwapInterval = null;
|
||
|
||
service.ExecuteDealInterests(
|
||
new List<swap_position> { position },
|
||
new List<eod_swap_position> { preEod },
|
||
settleDate, td, new List<swap_flow_event>(),
|
||
Principal, 0m, 0m, 1m, Principal);
|
||
|
||
Assert.IsTrue(service.PersistedPositions.Count > 0, "应生成eod");
|
||
var result = service.PersistedPositions[0];
|
||
// 普通日:InterestIncomeSum 应 = 前日 + 当天新计 = 1天 + 1天 = 2天
|
||
AssertDecimal(DailyInterest * 2, result.InterestIncomeSum,
|
||
$"普通日后 InterestIncomeSum 应=2天利息({DailyInterest * 2:F6})");
|
||
Console.WriteLine($"普通日归档:InterestIncomeSum={result.InterestIncomeSum:F6} = 2×{DailyInterest:F6} ✅");
|
||
}
|
||
|
||
/// <summary>
|
||
/// [DI_BRANCH_002] 互换日(hasSwap=true)→ 走 SaveEodInterestPosition 分支
|
||
/// ---------------------------------------------------------------
|
||
/// flowEvents 含 EventType=互换,hasSwap=true
|
||
/// → 应走 SaveEodInterestPosition(cs:330)
|
||
/// → 验证 PersistEodSwapPosition 被调用(生成了 eod)
|
||
/// ---------------------------------------------------------------
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void DI_BRANCH_002_互换日走SaveEodInterestPosition分支()
|
||
{
|
||
var service = new StubEodPositionService();
|
||
var td = CreateTrade();
|
||
var position = CreateInterestPosition();
|
||
var settleDate = new DateTime(2026, 5, 10);
|
||
var preEod = CreatePreEod(settleDate.AddDays(-1), DailyInterest * 13);
|
||
|
||
// 互换事件
|
||
var swapEvent = CreateSwapFlowEvent(settleDate, DailyInterest * 13);
|
||
position.InterestSwapInterval = null;
|
||
|
||
var interestList = new List<swap_position> { position };
|
||
var eodPositions = new List<eod_swap_position> { preEod };
|
||
|
||
service.ExecuteDealInterests(interestList, eodPositions, settleDate, td,
|
||
new List<swap_flow_event> { swapEvent },
|
||
Principal, 0m, 0m, 1m, Principal);
|
||
|
||
// 互换分支应生成1条 eod
|
||
Assert.AreEqual(1, service.PersistedPositions.Count, "互换分支应生成1条eod");
|
||
var result = service.PersistedPositions[0];
|
||
// InterestIncomeSum = pre + 当天新计 - 实现 ≈ 当天新计(攒的全付了)
|
||
AssertDecimal(DailyInterest, result.InterestIncomeSum, "互换结清后待实现≈当天新计利息");
|
||
Console.WriteLine($"互换日分支执行,InterestIncomeSum={result.InterestIncomeSum:F6} ≈ 当天新计({DailyInterest:F6}) ✅");
|
||
}
|
||
|
||
#endregion
|
||
|
||
// ================================================================
|
||
// 场景3:多日守恒——连续收盘归档,InterestIncomeSum 应线性递增
|
||
// ================================================================
|
||
|
||
#region 场景3:多日连续归档
|
||
|
||
/// <summary>
|
||
/// [DI_MULTI_001] 连续5天普通日收盘归档,InterestIncomeSum 每天递增1天利息
|
||
/// ---------------------------------------------------------------
|
||
/// 从4/27(首日)开始,连续收盘到5/1,验证 InterestIncomeSum 线性递增。
|
||
/// 每天收盘后 InterestIncomeSum 应 = 天数 × DailyInterest。
|
||
/// ---------------------------------------------------------------
|
||
/// </summary>
|
||
[TestMethod]
|
||
public void DI_MULTI_001_连续5天归档待实现线性递增()
|
||
{
|
||
var td = CreateTrade();
|
||
var position = CreateInterestPosition();
|
||
position.InterestSwapInterval = null; // 无观察日
|
||
|
||
decimal runningIncomeSum = 0m;
|
||
var runningDate = StartDate;
|
||
|
||
for (int day = 0; day < 5; day++)
|
||
{
|
||
var service = new StubEodPositionService();
|
||
var preEod = CreatePreEod(runningDate.AddDays(-1), runningIncomeSum);
|
||
|
||
service.ExecuteDealInterests(
|
||
new List<swap_position> { position },
|
||
new List<eod_swap_position> { preEod },
|
||
runningDate, td, new List<swap_flow_event>(),
|
||
Principal, 0m, 0m, 1m, Principal);
|
||
|
||
Assert.IsTrue(service.PersistedPositions.Count > 0, $"第{day + 1}天应生成eod");
|
||
var result = service.PersistedPositions[0];
|
||
|
||
// 首日 InterestIncomeSum = 1天利息,后续每天+1天利息
|
||
decimal expected = DailyInterest * (day + 1);
|
||
AssertDecimal(expected, result.InterestIncomeSum,
|
||
$"第{day + 1}天 InterestIncomeSum 应={(day + 1)}天利息");
|
||
|
||
runningIncomeSum = result.InterestIncomeSum;
|
||
runningDate = runningDate.AddDays(1);
|
||
}
|
||
|
||
Console.WriteLine($"连续5天归档:InterestIncomeSum 从0递增到{runningIncomeSum:F6} = 5×{DailyInterest:F6} ✅");
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
}
|