Files
zszq-trs/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs
T
张名锐 db46e48ec5 fix(swap): 修复互换产品到期结算和利息计算逻辑
- 优化到期日最终结算判断逻辑,确保只有完全覆盖才清零待实现余额
- 引入利息金额舍入处理,避免精度差异导致的结算误差
- 完善自动互换流水处理,确保实际结算金额与待实现计算分离
- 修复部分平仓后日终持仓继承逻辑,保持利息累计连续性
- 调整到期自动互换处理,确保最后结算后待实现余额正确归零
- 优化利息收入和费用的独立计算,避免历史数据重复计算问题
2026-07-29 17:37:57 +08:00

774 lines
42 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 Newtonsoft.Json;
using YLErp.DBModels;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// DealInterests 利息腿归档 - 合成单元测试(内存,不连库)
/// ============================================================================
/// 目标:验证收盘时利息腿 eod 的字段计算,覆盖三个分支:
/// ① 手动互换分支 SaveEodInterestPosition(我们修复 InterestIncomeSum 归零的核心)
/// ② 普通日分支 SaveEodInterestPositionCopyInterestIncomeSum 每日递增)
/// ③ 多日守恒(半平后多日再全平,利息一致性)
///
/// 模仿 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 : TestableSwapEodPositionService
{
/// <summary>
/// 自动互换场景可注入固定流水,避免为了验证结算边界而依赖真实计息公式。
/// 未赋值时仍走生产使用的真实 GetInterests 计算。
/// </summary>
public List<swap_flow_event> AutoInterests { get; set; }
public eod_swap_position LastInterestCalculationEodPosition { get; private set; }
public StubEodPositionService() : base(nameof(DealInterestsScenarioTest))
{
}
// 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)
{
LastInterestCalculationEodPosition = eodPositions.SingleOrDefault();
if (AutoInterests != null)
{
return AutoInterests;
}
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 包装:验证自动互换时的“高精度应结 -> 两位实际结算 -> 待实现尾差”链路。
public eod_swap_position ExecuteSaveAutoEodInterestPosition(
eod_swap_position eodPayPosition, swap_position position, trade td,
DateTime valueDate, IntervalModel interval)
{
SaveAutoEodInterestPosition(eodPayPosition, null, position, td, valueDate, interval,
null, DealInterestsScenarioTest.Principal, 0m, 1m, DealInterestsScenarioTest.Principal);
return PersistedPositions.LastOrDefault();
}
public eod_swap_position ExecuteSaveAutoEodWithCloseInterestPosition(
eod_swap_position eodPayPosition, swap_position position, trade td,
DateTime valueDate, IntervalModel interval, decimal posiLongNotional,
decimal posiShortNotional, List<swap_flow_event> flowEvents,
decimal closeNotional, bool autoSwap)
{
SaveAutoEodWithCloseInterestPosition(eodPayPosition, null, position, td, valueDate, interval,
posiLongNotional, posiShortNotional, flowEvents, closeNotional, autoSwap, 1m,
DealInterestsScenarioTest.Principal);
return PersistedPositions.LastOrDefault();
}
// public 包装:直接调用 protected virtual DealInterests(已改为 virtual,无需反射)
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)
{
DealInterests(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.完成
};
}
/// <summary>
/// 创建自动互换利息流水。InterestAmount 是高精度应结,生产入口负责将实际结算收敛到两位。
/// </summary>
private static swap_flow_event CreateAutoSwapFlowEvent(DateTime eventDate, decimal interestAmount)
{
return new swap_flow_event
{
id = 2002, SwapTradeId = 1, EventType = (int)SwapFlowEventTypeEnum.自动互换,
EventDate = eventDate, UnwindDate = eventDate, PositionId = 1001,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestAmount = interestAmount,
TdInterestAmount = 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/1013天),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
// ================================================================
// 场景2DealInterests 分支选择逻辑验证
// ================================================================
#region 场景2:分支选择
/// <summary>
/// [DI_BRANCH_001] 普通日(无互换无平仓无观察日)→ 走 copy 分支
/// ---------------------------------------------------------------
/// flowEvents 为空,insterval=nullhasSwap=falsehasClose=false
/// → 应走 SaveEodInterestPositionCopycs: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
/// → 应走 SaveEodInterestPositioncs: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} ✅");
}
// ================================================================
// 场景4:互换→收盘→再攒→再互换 守恒验证
// ================================================================
#region 场景4:多次互换结算守恒
/// <summary>
/// [DI_SWAP_MULTI_001] 攒10天→互换结清→再攒5天→再互换结清
/// ---------------------------------------------------------------
/// 验证:第一次互换后 InterestIncomeSum≈当天新计(攒的10天付了),
/// 再攒5天后 InterestIncomeSum≈6天(5天新攒+1天当天新计),
/// 第二次互换后 InterestIncomeSum≈当天新计(攒的6天又付了)。
///
/// 守恒约束:两次互换结算的 TdCloseInterest 之和 = 全程利息(15天+2天新计)。
/// </summary>
[TestMethod]
public void DI_SWAP_MULTI_001_多次互换结算守恒()
{
var td = CreateTrade();
var position = CreateInterestPosition();
// --- Phase 1: 攒10天(4/27~5/6),到5/6 ---
var date10 = StartDate.AddDays(10); // 5/7
decimal sum10days = Math.Round(Principal * FixedRate * 10 / AnnualDays,
ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
var preEod10 = CreatePreEod(date10.AddDays(-1), sum10days - DailyInterest); // 前日=9天
// 当天新计让它到10天
var svc1 = new StubEodPositionService();
svc1.ExecuteDealInterests(new List<swap_position> { position },
new List<eod_swap_position> { preEod10 }, date10, td,
new List<swap_flow_event>(), Principal, 0m, 0m, 1m, Principal);
var eod10days = svc1.PersistedPositions[0];
AssertDecimal(sum10days, eod10days.InterestIncomeSum, "10天后待实现应=10天利息");
Console.WriteLine($"Phase1: 攒10天 InterestIncomeSum={eod10days.InterestIncomeSum:F6}");
// --- Phase 2: 5/7 互换结清 ---
var swapDate1 = date10; // 同天互换
var svc2 = new StubEodPositionService();
var swapEvt1 = CreateSwapFlowEvent(swapDate1, sum10days);
var swapResult1 = svc2.ExecuteSaveEodInterestPosition(
eod10days, null, position, td, swapDate1, new List<swap_flow_event> { swapEvt1 });
// 互换后待实现≈当天新计(攒的10天付了,但当天又产生1天新计)
decimal dailyInc = Math.Round(Principal * FixedRate / AnnualDays,
ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
AssertDecimal(dailyInc, swapResult1.InterestIncomeSum, "第一次互换后待实现≈当天新计");
decimal firstRealized = swapResult1.TdCloseInterest;
Console.WriteLine($"Phase2: 第一次互换 TdCloseInterest={firstRealized:F6}, 待实现={swapResult1.InterestIncomeSum:F6}");
// --- Phase 3: 再攒5天 ---
decimal runningSum = swapResult1.InterestIncomeSum;
var runningDate = swapDate1.AddDays(1);
for (int i = 0; i < 5; i++)
{
var svc = new StubEodPositionService();
var preEod = CreatePreEod(runningDate.AddDays(-1), runningSum);
// 需要 preEod.RealizedInterest 累积
preEod.RealizedInterest = swapResult1.RealizedInterest;
svc.ExecuteDealInterests(new List<swap_position> { position },
new List<eod_swap_position> { preEod }, runningDate, td,
new List<swap_flow_event>(), Principal, 0m, 0m, 1m, Principal);
runningSum = svc.PersistedPositions[0].InterestIncomeSum;
runningDate = runningDate.AddDays(1);
}
Console.WriteLine($"Phase3: 再攒5天后 InterestIncomeSum={runningSum:F6}");
// --- Phase 4: 再互换结清 ---
var svc4 = new StubEodPositionService();
var preEodFinal = CreatePreEod(runningDate.AddDays(-1), runningSum);
preEodFinal.RealizedInterest = swapResult1.RealizedInterest;
var swapEvt2 = CreateSwapFlowEvent(runningDate, runningSum);
var swapResult2 = svc4.ExecuteSaveEodInterestPosition(
preEodFinal, null, position, td, runningDate, new List<swap_flow_event> { swapEvt2 });
decimal secondRealized = swapResult2.TdCloseInterest;
Console.WriteLine($"Phase4: 第二次互换 TdCloseInterest={secondRealized:F6}, 待实现={swapResult2.InterestIncomeSum:F6}");
// 守恒:两次互换实现的 + 最终待实现 = 全程天数 × dailyInc
// 全程天数 = 10天(Phase1) + 1天(第一次互换当天新计) + 5天(Phase3) + 1天(第二次互换当天新计) = 17天
// 但第一次互换的当天新计进了 InterestIncomeSum 没进 TdCloseInterest
// 第二次互换同理。所以守恒 = RealizedInterest合计 + 最终InterestIncomeSum = 全程利息
decimal totalDays = 10 + 1 + 5 + 1; // 17天
decimal expectedTotalInterest = Math.Round(Principal * FixedRate * totalDays / AnnualDays,
ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
decimal actualTotal = swapResult2.RealizedInterest + swapResult2.InterestIncomeSum;
Console.WriteLine($"守恒: RealizedInterest({swapResult2.RealizedInterest:F6}) + InterestIncomeSum({swapResult2.InterestIncomeSum:F6}) = {actualTotal:F6}");
Console.WriteLine($"期望: {totalDays}天 × {dailyInc:F6} = {expectedTotalInterest:F6}");
AssertDecimal(expectedTotalInterest, actualTotal,
"已实现+待实现 应=全程利息(守恒)");
}
#endregion
// ================================================================
// 场景5:预付金腿(marginTypes ratio 翻转)符号验证
// ================================================================
#region 场景5:预付金腿 ratio 翻转
/// <summary>
/// [DI_MARGIN_001] 预付金腿互换结清后 RealizedInterest 应为负(支付方向)
/// ---------------------------------------------------------------
/// 预付金腿 InterestDirection=收取(1),但 marginTypes 会把 ratio 翻转为 -1。
/// SwapPositionValue 应为负(负债),RealizedInterest 也应为负(券商支付)。
/// 验证 cs:789-793 的 ratio 翻转逻辑。
/// ---------------------------------------------------------------
/// </summary>
[TestMethod]
public void DI_MARGIN_001_预付金腿RealizedInterest为负()
{
var service = new StubEodPositionService();
var td = CreateTrade();
var settleDate = new DateTime(2026, 5, 10);
// 预付金腿(初始预付金 InterestMode=5InterestDirection=收取)
var marginPosition = new swap_position
{
id = 2001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.初始预付金,
InterestRateDefault = 0.005m, InterestPrincipalFix = 500m,
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利,
IsAnnualized = true, interest_rest_days = 1, interest_rule = 0,
FloatRateUnderlyingCode = null,
InterestSwapInterval = null
};
// 攒10天的预付金利息
decimal marginDaily = Math.Round(500m * 0.005m / AnnualDays,
ConsGlobal.PriceRound, MidpointRounding.AwayFromZero);
decimal margin10days = marginDaily * 10;
var preEod = new eod_swap_position
{
id = 200, SwapTradeId = 1, PositionId = 2001, ValueDate = settleDate.AddDays(-1),
ClientId = 999998, InterestDirection = (int)SwapDirectionEnum.收取,
InterestMode = (int)InterestModeEnum.初始预付金,
InterestIncomeSum = margin10days, InterestProfitSum = margin10days,
RealizedInterest = 0m, InterestRateDefault = 0.005m,
TdInterestPrincipal = 500m, PosiNotionalValue = 500m,
InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true,
interest_rest_days = 1, FloatRate = 0m
};
// 互换结清
var swapEvent = new swap_flow_event
{
id = 3001, SwapTradeId = 1, EventType = (int)SwapFlowEventTypeEnum.互换,
EventDate = settleDate, UnwindDate = settleDate, PositionId = 2001,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestAmount = margin10days, InterestClosePnL = -margin10days, // 预付金 ratio 翻转后为负
InterestRate = 0.005m, InterestMode = (int)InterestModeEnum.初始预付金,
InterestPrincipal = 500m, FloatRate = 0m,
DataState = (int)SwapFlowDateStateEnum.完成
};
var result = service.ExecuteSaveEodInterestPosition(
preEod, null, marginPosition, td, settleDate, new List<swap_flow_event> { swapEvent });
// 预付金 marginTypes 翻转 ratio=-1
// RealizedInterest = 0 + TdCloseInterest(margin10days) * ratio(-1) = -margin10days
AssertDecimal(-margin10days, result.RealizedInterest,
"预付金腿 RealizedInterest 应为负(ratio翻转后支付方向)");
Console.WriteLine($"预付金腿 RealizedInterest={result.RealizedInterest:F6}(负=支付)✅");
Console.WriteLine($"SwapPositionValue={result.SwapPositionValue:F6}(应≈当天新计×ratio=-正)");
}
#endregion
// ================================================================
// 场景6:自动互换两位实际结算与到期清零
// ================================================================
#region 场景6:自动互换尾差与最终结算
/// <summary>
/// [DI_AUTO_SETTLEMENT_001] 非最终自动互换:实际结算按两位,尾差继续保留在待实现。
/// 0.0082 四舍五入后实际结算 0.01,待实现应为 0.0082 - 0.01 = -0.0018。
/// </summary>
[TestMethod]
public void DI_AUTO_SETTLEMENT_001_非最终自动互换保留舍入尾差()
{
var service = new StubEodPositionService
{
AutoInterests = new List<swap_flow_event> { CreateAutoSwapFlowEvent(new DateTime(2026, 5, 10), 0.0082m) }
};
var td = CreateTrade();
var position = CreateInterestPosition();
var result = service.ExecuteSaveAutoEodInterestPosition(
CreatePreEod(new DateTime(2026, 5, 9), 0m), position, td, new DateTime(2026, 5, 10),
new IntervalModel { Date = new DateTime(2026, 5, 10), Rate = FixedRate, Settlement = 1 });
AssertDecimal(0.01m, result.TdCloseInterest, "日终当日已实现必须使用两位实际结算金额");
AssertDecimal(-0.0018m, result.InterestIncomeSum, "非最终结算的尾差必须继续留在待实现");
AssertDecimal(0.01m, service.AutoInterests.Single().InterestAmount, "自动互换流水金额必须为两位");
AssertDecimal(0.01m, service.AutoInterests.Single().InterestClosePnL, "资金汇总使用的流水损益必须为两位");
}
/// <summary>
/// [DI_AUTO_SETTLEMENT_002] 到期自动互换:仍按两位实际结算,但不存在后续计息时待实现必须清零。
/// </summary>
[TestMethod]
public void DI_AUTO_SETTLEMENT_002_到期自动互换清零待实现()
{
var service = new StubEodPositionService
{
AutoInterests = new List<swap_flow_event> { CreateAutoSwapFlowEvent(ExerciseDate, 0.0082m) }
};
var td = CreateTrade();
var position = CreateInterestPosition();
var result = service.ExecuteSaveAutoEodInterestPosition(
CreatePreEod(ExerciseDate.AddDays(-1), 0m), position, td, ExerciseDate,
new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 1 });
AssertDecimal(0.01m, result.TdCloseInterest, "到期自动结算仍按金额两位落库");
AssertDecimal(0m, result.InterestIncomeSum, "到期最终自动结算后不得遗留待实现尾差");
}
/// <summary>
/// [DI_AUTO_SETTLEMENT_003] 自动互换后的后续部分平仓必须续接尾差和累计已实现。
/// 7/7 自动互换将 0.008191780822 按 0.01 实际结算,留下 -0.001808219178
/// 7/8 平仓一半后,待实现继续参与计算,7/9 全平时才清零。
/// </summary>
[TestMethod]
public void DI_AUTO_SETTLEMENT_003_自动互换后部分平仓续接尾差和已实现()
{
var service = new StubEodPositionService
{
AutoInterests = new List<swap_flow_event>
{
CreateAutoSwapFlowEvent(new DateTime(2026, 5, 10), 0.008191780822m)
}
};
var td = CreateTrade();
var position = CreateInterestPosition();
var autoResult = service.ExecuteSaveAutoEodInterestPosition(
CreatePreEod(new DateTime(2026, 5, 9), 0m), position, td, new DateTime(2026, 5, 10),
new IntervalModel { Date = new DateTime(2026, 5, 10), Rate = FixedRate, Settlement = 1 });
var firstCloseDate = new DateTime(2026, 5, 11);
var firstCloseFlow = CreateSwapFlowEvent(firstCloseDate, 0.01m);
firstCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓;
firstCloseFlow.InterestPrincipal = 50m;
// 模拟 CalcUnwindInterest: 上日尾差 + 本次平仓后的高精度待实现。
service.AutoInterests = new List<swap_flow_event>
{
CreateAutoSwapFlowEvent(firstCloseDate, 0.006383561644m)
};
service.AutoInterests[0].InterestPrincipal = 50m;
var firstCloseResult = service.ExecuteSaveAutoEodWithCloseInterestPosition(
autoResult, position, td, firstCloseDate, null, 50m, 0m,
new List<swap_flow_event> { firstCloseFlow }, 50m, false);
AssertDecimal(-0.001808219178m, service.LastInterestCalculationEodPosition.InterestProfitSum,
"部分平仓计算必须带入自动互换遗留的待实现尾差");
Assert.AreEqual(position.id, service.LastInterestCalculationEodPosition.PositionId,
"部分平仓计息必须按腿标识匹配上一日日终");
AssertDecimal(0.006383561644m, firstCloseResult.InterestIncomeSum,
"部分平仓后待实现应延续历史尾差");
AssertDecimal(0.02m, firstCloseResult.RealizedInterest,
"部分平仓后累计已实现应包含此前自动互换和本次平仓");
var finalCloseDate = firstCloseDate.AddDays(1);
var finalCloseFlow = CreateSwapFlowEvent(finalCloseDate, 0.01m);
finalCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓;
finalCloseFlow.InterestPrincipal = 50m;
service.AutoInterests = new List<swap_flow_event>
{
CreateAutoSwapFlowEvent(finalCloseDate, 0.010479452055m)
};
var finalCloseResult = service.ExecuteSaveAutoEodWithCloseInterestPosition(
firstCloseResult, position, td, finalCloseDate, null, 0m, 0m,
new List<swap_flow_event> { finalCloseFlow }, 50m, false);
AssertDecimal(0m, finalCloseResult.InterestIncomeSum, "全平后待实现应清零");
AssertDecimal(0.03m, finalCloseResult.RealizedInterest,
"全平后累计已实现应包含自动互换和两次平仓");
}
/// <summary>
/// [DI_MATURITY_SETTLEMENT_001] 到期日存在手动互换但未带齐待实现时不能清零;
/// 当前事件按两位覆盖全部可结金额后,才可视为最终结算并清零。
/// </summary>
[TestMethod]
public void DI_MATURITY_SETTLEMENT_001_到期手动互换仅在结清后清零()
{
var settleDate = ExerciseDate;
var td = CreateTrade();
var position = CreateInterestPosition();
position.InterestPrincipalFix = 0m;
// 事件未结算此前的 0.0082:到期日也必须保留待实现。
var incompleteService = new StubEodPositionService();
var incompleteEvent = CreateSwapFlowEvent(settleDate, 0m);
incompleteEvent.InterestPrincipal = 0m;
incompleteEvent.InterestRate = 0m;
var incompleteResult = incompleteService.ExecuteSaveEodInterestPosition(
CreatePreEod(settleDate.AddDays(-1), 0.0082m), null, position, td, settleDate,
new List<swap_flow_event> { incompleteEvent });
AssertDecimal(0.0082m, incompleteResult.InterestIncomeSum, "到期但未结清时不得丢弃历史待实现");
// 前端按两位提交 0.01,可覆盖 0.0082 的最终金额,允许清零。
var finalService = new StubEodPositionService();
var finalEvent = CreateSwapFlowEvent(settleDate, 0.01m);
finalEvent.InterestPrincipal = 0m;
finalEvent.InterestRate = 0m;
var finalResult = finalService.ExecuteSaveEodInterestPosition(
CreatePreEod(settleDate.AddDays(-1), 0.0082m), null, position, td, settleDate,
new List<swap_flow_event> { finalEvent });
AssertDecimal(0m, finalResult.InterestIncomeSum, "两位最终结算覆盖待实现后应清零");
}
#endregion
#endregion
}
}