Files
zszq-trs/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs
T
张名锐 7528670e8b fix(swap): 修复部分平仓后算尾利息计算问题(算头算尾T+0合约单利)
- 将测试方法改为数据驱动测试以覆盖不同利息模式
- 在测试中添加对不同利息模式的验证逻辑
- 修复平仓后EOD本金只携带剩余持仓的计算逻辑
- 添加对算尾部分平仓后EOD本金的断言验证
- 优化单利算尾当日按平仓前全额计提的处理逻辑
2026-08-09 13:42:59 +08:00

1752 lines
92 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 SwapDealService DealService { 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 (DealService ?? 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, eod_swap lastEodSwap = null,
decimal posiLongNotional = DealInterestsScenarioTest.Principal,
decimal orginPv = DealInterestsScenarioTest.Principal)
{
SaveAutoEodInterestPosition(eodPayPosition, null, position, td, valueDate, interval,
lastEodSwap, posiLongNotional, 0m, 1m, orginPv);
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);
}
}
private sealed class StubCompoundSwapDealService : SwapDealService
{
private readonly IReadOnlyDictionary<DateTime, double> _floatRates;
public StubCompoundSwapDealService(IReadOnlyDictionary<DateTime, double> floatRates = null)
: base(new OptUserInfo(0, nameof(StubCompoundSwapDealService), OptUserFrom.UnitTest))
{
_floatRates = floatRates ?? new Dictionary<DateTime, double>();
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
return _floatRates.TryGetValue(valueDate.Date, out rate);
}
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
{
return 0m;
}
}
#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:上日尾差加当日新增,尚未扣除本次 0.01 平仓结算。
service.AutoInterests = new List<swap_flow_event>
{
CreateAutoSwapFlowEvent(firstCloseDate, 0.016383561644m)
};
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.010438356164m, 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,
"全平后累计已实现应包含自动互换和两次平仓");
}
[TestMethod]
public void DI_AUTO_SETTLEMENT_004_PartialCloseAccruesOnlyAfterPreviousEod()
{
const decimal originalNotional = 10012.35m;
const decimal remainingNotional = 5006.17m;
const decimal closeNotional = 5006.172835m;
const decimal rate = 0.0299m;
const decimal pendingInterest = 0.820379534246m;
const decimal settledInterest = 0.82m;
const decimal expectedPendingInterest = 0.410474008219m;
var service = new StubEodPositionService();
var td = CreateTrade();
td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "01",
SettlementRules = 0
});
var position = CreateInterestPosition();
position.InterestRateDefault = rate;
position.InterestSwapInterval = null;
var previousEodDate = StartDate.AddDays(2);
var closeDate = previousEodDate.AddDays(1);
var previousEod = CreatePreEod(previousEodDate, pendingInterest, settledInterest);
previousEod.TdInterestPrincipal = originalNotional;
var closeFlow = CreateSwapFlowEvent(closeDate, settledInterest);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓;
closeFlow.InterestPrincipal = 5006.18m;
closeFlow.InterestRate = rate;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
previousEod, position, td, closeDate, null,
remainingNotional, 0m, new List<swap_flow_event> { closeFlow },
closeNotional, false);
AssertDecimal(expectedPendingInterest, result.InterestIncomeSum,
"Partial close must accrue only the day after the previous EOD snapshot");
AssertDecimal(remainingNotional, result.TdInterestPrincipal,
"The close-day snapshot must carry the remaining principal into the next EOD");
AssertDecimal(1.64m, result.RealizedInterest,
"Realized interest must include the previous and current settlements");
Assert.AreEqual(previousEodDate, service.LastInterestCalculationEodPosition.ValueDate,
"The previous EOD ValueDate must be preserved for accrual boundaries");
Assert.AreEqual(previousEod.id, service.LastInterestCalculationEodPosition.id,
"The previous EOD identity must not be reset to a new position");
}
[TestMethod]
public void DI_COMPOUND_RESET_PARTIAL_CLOSE_UsesCalculatedPrincipal()
{
const decimal remainingNotional = 500m;
const decimal compoundPrincipalAfterSevenDays = 500.958904m;
var closeDate = StartDate.AddDays(7);
var service = new StubEodPositionService
{
AutoInterests = new List<swap_flow_event>
{
new swap_flow_event
{
InterestPrincipal = compoundPrincipalAfterSevenDays,
InterestRate = FixedRate,
InterestAmount = 0m,
TdInterestAmount = 0m
}
}
};
var td = CreateTrade();
var position = CreateInterestPosition();
position.InterestType = (int)InterestTypeEnum.复利;
position.interest_rest_days = 7;
var closeFlow = CreateSwapFlowEvent(closeDate, 0m);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
CreatePreEod(closeDate.AddDays(-1), 0m), position, td, closeDate, null,
remainingNotional, 0m, new List<swap_flow_event> { closeFlow }, remainingNotional, false);
AssertDecimal(compoundPrincipalAfterSevenDays, result.TdInterestPrincipal,
"复利重置日部分平仓后,EOD 本金必须保留 CalcSwapInterests 计算的 7 天复利本金");
}
[TestMethod]
public void DI_MANUAL_PREPAY_PARTIAL_CLOSE_UsesHistoryPlusRemainingDailyInterest()
{
var service = new StubEodPositionService();
var td = CreateTrade();
var position = CreateInterestPosition();
position.InterestMode = (int)InterestModeEnum.初始预付金;
position.InterestPrincipalFix = Principal;
var previousEod = CreatePreEod(StartDate.AddDays(2), 100m);
previousEod.InterestMode = position.InterestMode;
previousEod.TdInterestPrincipal = Principal;
var closeFlow = CreateSwapFlowEvent(StartDate.AddDays(3), 50m);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓;
closeFlow.InterestPrincipal = 500m;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
previousEod, position, td, StartDate.AddDays(3), null,
500m, 0m, new List<swap_flow_event> { closeFlow }, 500m, false);
var expected = previousEod.InterestIncomeSum
+ result.TdInterestIncome - result.TdCloseInterest;
AssertDecimal(expected, result.InterestIncomeSum,
"预付金部分平仓待实现收益应为历史待实现+平仓后当日新增-平仓实现");
}
[DataTestMethod]
[DataRow((int)InterestModeEnum.合约名义本金规模)]
[DataRow((int)InterestModeEnum.标的期初全价)]
public void DI_MANUAL_PARTIAL_CLOSE_CalcLastIncludesClosedPrincipalDailyInterest(int interestMode)
{
var service = new StubEodPositionService();
var td = CreateTrade();
td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11",
SettlementRules = 0
});
var position = CreateInterestPosition();
position.InterestMode = interestMode;
var previousEod = CreatePreEod(StartDate.AddDays(2), 100m);
previousEod.TdInterestPrincipal = Principal;
var closeFlow = CreateSwapFlowEvent(StartDate.AddDays(3), 50m);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
previousEod, position, td, StartDate.AddDays(3), null,
500m, 0m, new List<swap_flow_event> { closeFlow }, 500m, false);
AssertDecimal(Principal * FixedRate / AnnualDays, result.TdInterestIncome,
"算尾部分平仓的当日新计利息应包含已平仓部分(按全额本金计提)");
AssertDecimal(500m, result.TdInterestPrincipal,
"算尾部分平仓后的 EOD 本金应只携带剩余持仓");
// 补充:算尾部分平仓的待实现利息总额应满足递推
// InterestIncomeSum = 前日待实现 + 当日新计(全额本金,含被平仓部分) - 当日实现
// 钉死"算尾 → 被平仓部分多1天利息 → 进入待实现总额"的完整链条
decimal expectedIncomeSum = previousEod.InterestIncomeSum + result.TdInterestIncome - result.TdCloseInterest;
AssertDecimal(expectedIncomeSum, result.InterestIncomeSum,
"算尾部分平仓:InterestIncomeSum 应=前日待实现+当日新计(含被平仓部分)-当日实现");
}
[TestMethod]
public void DI_MANUAL_PARTIAL_CLOSE_NoCalcLastUsesRemainingPrincipalDailyInterest()
{
var service = new StubEodPositionService();
var td = CreateTrade();
var position = CreateInterestPosition();
var previousEod = CreatePreEod(StartDate.AddDays(2), 100m);
previousEod.TdInterestPrincipal = Principal;
var closeFlow = CreateSwapFlowEvent(StartDate.AddDays(3), 50m);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
previousEod, position, td, StartDate.AddDays(3), null,
500m, 0m, new List<swap_flow_event> { closeFlow }, 500m, false);
AssertDecimal(500m * FixedRate / AnnualDays, result.TdInterestIncome,
"不算尾部分平仓的当日新计利息只应包含剩余持仓部分(按剩余本金计提)");
// 补充对照:不算尾时待实现利息总额同样满足递推,但当日新计只含剩余持仓
// 与 CalcLast 测试对照:不算尾的 TdInterestIncome 更小(差额=被平仓部分1天利息),
// 因此 InterestIncomeSum 也相应更小——证明算尾/不算尾的差异确实传导到待实现总额
decimal expectedIncomeSum = previousEod.InterestIncomeSum + result.TdInterestIncome - result.TdCloseInterest;
AssertDecimal(expectedIncomeSum, result.InterestIncomeSum,
"不算尾部分平仓:InterestIncomeSum 应=前日待实现+当日新计(仅剩余持仓)-当日实现");
}
[TestMethod]
public void DI_AUTO_SETTLEMENT_005_AutoSettlementKeepsRemainingPrincipal()
{
const decimal originalNotional = 10012.35m;
const decimal remainingNotional = 5006.17m;
const decimal rate = 0.0299m;
var settlementDate = new DateTime(2026, 7, 14);
var service = new StubEodPositionService();
var td = CreateTrade();
td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "01",
SettlementRules = 0
});
var position = CreateInterestPosition();
position.InterestRateDefault = rate;
position.InterestSwapInterval = null;
var previousEod = CreatePreEod(settlementDate.AddDays(-1), 2.460947197259m, 1.64m);
previousEod.TdInterestPrincipal = remainingNotional;
var staleAggregate = new eod_swap { NotionalValue = originalNotional };
var result = service.ExecuteSaveAutoEodInterestPosition(
previousEod, position, td, settlementDate,
new IntervalModel { Date = settlementDate, Rate = rate, Settlement = 1 },
staleAggregate, remainingNotional, originalNotional);
AssertDecimal(2.87m, result.TdCloseInterest,
"Automatic settlement must round the half-position interest to 2.87");
AssertDecimal(remainingNotional, result.TdInterestPrincipal,
"Automatic settlement must not restore the original principal from eod_swap");
}
[TestMethod]
public void DI_AUTO_SETTLEMENT_006_CloseAndAutoSettlementOnlySettlesRemainder()
{
var settleDate = new DateTime(2026, 7, 16);
var autoFlow = CreateAutoSwapFlowEvent(settleDate, 1.2345m);
var service = new StubEodPositionService
{
AutoInterests = new List<swap_flow_event> { autoFlow }
};
var closeFlow = CreateSwapFlowEvent(settleDate, 0.50m);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
CreatePreEod(settleDate.AddDays(-1), 0m), CreateInterestPosition(), CreateTrade(),
settleDate, new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 },
50m, 0m, new List<swap_flow_event> { closeFlow }, 50m, true);
AssertDecimal(0.73m, autoFlow.InterestAmount,
"Automatic settlement must deduct the 0.50 already settled by the close");
AssertDecimal(0.73m, autoFlow.InterestClosePnL,
"The automatic flow PnL must use the actual 2-decimal remainder");
AssertDecimal(1.23m, result.TdCloseInterest,
"EOD realized interest must include both manual and automatic settlements");
AssertDecimal(0.0045m, result.InterestIncomeSum,
"The high-precision total less actual settlements must remain unrealized");
AssertDecimal(1.23m, result.RealizedInterest,
"Cumulative realized interest must add the combined actual settlement once");
}
[TestMethod]
public void DI_AUTO_SETTLEMENT_007_PayLegKeepsUnsignedSettlementAndAppliesDirectionOnce()
{
var settleDate = new DateTime(2026, 7, 16);
var autoFlow = CreateAutoSwapFlowEvent(settleDate, 1.2345m);
autoFlow.InterestDirection = (int)SwapDirectionEnum.支付;
autoFlow.InterestClosePnL = -1.2345m;
var service = new StubEodPositionService
{
AutoInterests = new List<swap_flow_event> { autoFlow }
};
var position = CreateInterestPosition();
position.InterestDirection = (int)SwapDirectionEnum.支付;
var closeFlow = CreateSwapFlowEvent(settleDate, 0.50m);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓;
closeFlow.InterestDirection = (int)SwapDirectionEnum.支付;
closeFlow.InterestClosePnL = -0.50m;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
CreatePreEod(settleDate.AddDays(-1), 0m), position, CreateTrade(), settleDate,
new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 },
50m, 0m, new List<swap_flow_event> { closeFlow }, 50m, true);
AssertDecimal(0.73m, autoFlow.InterestAmount);
AssertDecimal(-0.73m, autoFlow.InterestClosePnL);
AssertDecimal(1.23m, result.TdCloseInterest,
"TdCloseInterest follows the unsigned settlement convention used by other interest branches");
AssertDecimal(0.0045m, result.InterestIncomeSum);
AssertDecimal(-1.23m, result.RealizedInterest,
"The pay direction must be applied exactly once when cumulative realized interest is stored");
}
[TestMethod]
public void DI_AUTO_SETTLEMENT_008_MarginLegAppliesReversedDirectionOnce()
{
var settleDate = new DateTime(2026, 7, 16);
var autoFlow = CreateAutoSwapFlowEvent(settleDate, 1.2345m);
autoFlow.InterestMode = (int)InterestModeEnum.初始预付金;
var service = new StubEodPositionService
{
AutoInterests = new List<swap_flow_event> { autoFlow }
};
var position = CreateInterestPosition();
position.InterestMode = (int)InterestModeEnum.初始预付金;
position.InterestPrincipalFix = 50m;
var closeFlow = CreateSwapFlowEvent(settleDate, 0.50m);
closeFlow.EventType = (int)SwapFlowEventTypeEnum.平仓;
closeFlow.InterestMode = (int)InterestModeEnum.初始预付金;
var result = service.ExecuteSaveAutoEodWithCloseInterestPosition(
CreatePreEod(settleDate.AddDays(-1), 0m), position, CreateTrade(), settleDate,
new IntervalModel { Date = settleDate, Rate = FixedRate, Settlement = 1 },
50m, 0m, new List<swap_flow_event> { closeFlow }, 50m, true);
AssertDecimal(0.73m, autoFlow.InterestAmount);
AssertDecimal(0.73m, autoFlow.InterestClosePnL);
AssertDecimal(1.23m, result.TdCloseInterest);
AssertDecimal(0.0045m, result.InterestIncomeSum);
AssertDecimal(-1.23m, result.RealizedInterest,
"A received margin principal produces payable interest, so the margin ratio reverses once");
}
[TestMethod]
public void DI_MANUAL_CLOSE_006_FinalCloseIncludesCloseDateInterest()
{
const decimal originalNotional = 10012.35m;
const decimal remainingNotional = 5006.17m;
const decimal rate = 0.0299m;
const decimal pendingInterest = 0.411136145205m;
const decimal expectedInterest = 0.821230619178m;
var closeDate = new DateTime(2026, 7, 16);
var service = new StubEodPositionService();
var td = CreateTrade();
td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "01",
SettlementRules = 0
});
var position = CreateInterestPosition();
position.InterestRateDefault = rate;
position.InterestSwapInterval = null;
var previousEod = CreatePreEod(closeDate.AddDays(-1), pendingInterest, 4.51m);
previousEod.TdInterestPrincipal = remainingNotional;
var previousFloatingPosition = new eod_swap_position
{
PosiDirection = (int)SwapDirectionEnum.支付,
PosiNotionalValue = remainingNotional
};
var previousAggregate = new eod_swap
{
NotionalValue = originalNotional,
NotionalValueLong = remainingNotional
};
var orginPv = SwapDealService.ResolveUnwindPreviousNotional(
previousAggregate, new List<eod_swap_position> { previousEod, previousFloatingPosition },
remainingNotional);
AssertDecimal(remainingNotional, SwapDealService.ResolveUnwindPreviousNotional(
previousAggregate, Array.Empty<eod_swap_position>(), originalNotional),
"Missing details must fall back to the aggregate directional notionals");
AssertDecimal(remainingNotional, SwapDealService.ResolveUnwindPreviousNotional(
null, null, remainingNotional),
"Missing EOD data must fall back to the current notional");
AssertDecimal(remainingNotional, SwapDealService.ResolveUnwindPreviousNotional(
new eod_swap { NotionalValue = originalNotional }, Array.Empty<eod_swap_position>(),
remainingNotional),
"Zero directional notionals must not override a non-zero current remaining notional");
var result = new SwapDealService(service).GetInterests(
td, td.trade_extend, closeDate, closeDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum.平仓, false, false, 1m, orginPv,
false, settment: false, newCalcLast: false, closeList: null).Single();
AssertDecimal(remainingNotional, result.InterestPrincipal,
"Final close must accrue on the remaining principal");
AssertDecimal(expectedInterest, result.InterestAmount,
"InterestCalcMode 01 must include the final close date");
AssertDecimal(0.82m, Math.Round(result.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero), "Final close cash interest must be 0.82");
}
[TestMethod]
public void DI_MANUAL_CLOSE_007_CompoundFinalCloseSettlesFirstPartialCloseRoundingTail()
{
const decimal originalNotional = 10012.35m;
const decimal remainingNotional = originalNotional / 2m;
const decimal rate = 0.0299m;
var firstCloseDate = StartDate.AddDays(6);
var finalCloseDate = firstCloseDate.AddDays(6);
var td = CreateTrade();
td.trade_extend.ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11",
SettlementRules = 0
});
var position = CreateInterestPosition();
position.InterestType = (int)InterestTypeEnum.复利;
position.InterestRateDefault = rate;
position.InterestPrincipalFix = originalNotional;
position.interest_rest_days = 1;
position.InterestSwapInterval = null;
var dealService = new StubCompoundSwapDealService();
var eodService = new StubEodPositionService { DealService = dealService };
var firstCloseInterest = dealService.GetInterests(
td, td.trade_extend, firstCloseDate, firstCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
originalNotional, originalNotional, 0m, remainingNotional, 0.5m,
(int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional,
settment: false).Single();
var firstCloseCash = Math.Round(firstCloseInterest.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
var firstCloseRoundingTail = firstCloseInterest.InterestAmount - firstCloseCash;
Assert.AreNotEqual(0m, firstCloseRoundingTail,
$"首次部分平仓高精度利息 {firstCloseInterest.InterestAmount:F12} 按两位实际结算 {firstCloseCash:F2},尾差 {firstCloseRoundingTail:F12}");
var firstCloseFlow = CreateSwapFlowEvent(firstCloseDate, firstCloseCash);
firstCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓;
firstCloseFlow.InterestPrincipal = remainingNotional;
var firstCloseEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
null, position, td, firstCloseDate, null, remainingNotional, 0m,
new List<swap_flow_event> { firstCloseFlow }, remainingNotional, false);
var replayAtPreviousEod = dealService.GetInterests(
td, td.trade_extend, firstCloseDate, firstCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional,
settment: false).Single();
var replayAtFinalClose = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional,
settment: false).Single();
var expectedFinalInterest = firstCloseEod.InterestIncomeSum
+ replayAtFinalClose.InterestAmount - replayAtPreviousEod.InterestAmount;
var expectedFinalCash = Math.Round(expectedFinalInterest, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
var expectedTotalCash = Math.Round(firstCloseCash + firstCloseEod.InterestIncomeSum
+ replayAtFinalClose.InterestAmount - replayAtPreviousEod.InterestAmount,
ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero);
var finalCloseInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { firstCloseEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional,
settment: false).Single();
var finalCloseCash = Math.Round(finalCloseInterest.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero);
var finalCloseFlow = CreateSwapFlowEvent(finalCloseDate, finalCloseCash);
finalCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓;
finalCloseFlow.InterestPrincipal = remainingNotional;
var finalCloseEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
firstCloseEod, position, td, finalCloseDate, null, 0m, 0m,
new List<swap_flow_event> { finalCloseFlow }, remainingNotional, false);
AssertDecimal(expectedFinalCash, finalCloseCash,
"最终全平现金必须带走上一日日终的待实现利息尾差");
AssertDecimal(expectedTotalCash,
firstCloseCash + finalCloseCash,
"两次实际结算现金必须守恒");
AssertDecimal(0m, finalCloseEod.InterestIncomeSum, "全平且实际金额覆盖应结利息后待实现应清零");
AssertDecimal(firstCloseCash + finalCloseCash, finalCloseEod.RealizedInterest,
"累计已实现利息必须等于历次实际结算金额之和");
var incompleteFinalCloseFlow = CreateSwapFlowEvent(finalCloseDate, finalCloseCash - 0.01m);
incompleteFinalCloseFlow.EventType = (int)SwapFlowEventTypeEnum.平仓;
incompleteFinalCloseFlow.InterestPrincipal = remainingNotional;
var incompleteFinalCloseEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
firstCloseEod, position, td, finalCloseDate, null, 0m, 0m,
new List<swap_flow_event> { incompleteFinalCloseFlow }, remainingNotional, false);
AssertDecimal(expectedFinalInterest - incompleteFinalCloseFlow.InterestAmount,
incompleteFinalCloseEod.InterestIncomeSum,
"最终全平流水少结 0.01 时,日终必须保留未结利息而非清零");
}
[TestMethod]
public void DI_GLMS_20260421_0007_ContractNotionalCompoundPartialCloseScalesAndFinalCloseUsesRemainingPrincipal()
{
const decimal notional = 303139117.8m;
const decimal partialPercent = 0.3m;
const decimal partialNotional = notional * partialPercent;
const decimal remainingNotional = notional - partialNotional;
const decimal spread = 0.0025m;
var startDate = new DateTime(2026, 4, 21);
var maturityDate = new DateTime(2026, 5, 19);
var partialCloseDate = new DateTime(2026, 5, 11);
var td = new trade
{
id = 7007,
TradeNumber = "GLMS-20260421-0007",
ClientId = 999998,
TradeType = "收益互换",
TradeDate = startDate,
StartDate = startDate,
ExerciseDate = maturityDate,
TradeStatus = "确认成交",
ValidState = "Valid",
trade_extend = new trade_extend
{
TradeId = 7007,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11",
SettlementRules = 0
})
}
};
var position = new swap_position
{
id = 70071,
SwapTradeId = td.id,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestMode = (int)InterestModeEnum.合约名义本金规模,
InterestRateDefault = spread,
InterestPrincipalFix = notional,
PosiStartDate = startDate,
PosiMatuirityDate = maturityDate,
IsInitial = true,
Invalid = false,
InterestType = (int)InterestTypeEnum.复利,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = maturityDate, Rate = spread, Settlement = 0 }
})
};
var service = new StubCompoundSwapDealService(new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 20)] = 0.0132,
[new DateTime(2026, 4, 21)] = 0.0132,
[new DateTime(2026, 4, 22)] = 0.0132,
[new DateTime(2026, 4, 23)] = 0.0132,
[new DateTime(2026, 4, 24)] = 0.0131,
[new DateTime(2026, 4, 27)] = 0.013502,
[new DateTime(2026, 4, 28)] = 0.0136,
[new DateTime(2026, 4, 29)] = 0.0138,
[new DateTime(2026, 4, 30)] = 0.0139,
[new DateTime(2026, 5, 4)] = 0.0139,
[new DateTime(2026, 5, 5)] = 0.0139,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 7)] = 0.0136,
[new DateTime(2026, 5, 8)] = 0.0135,
[new DateTime(2026, 5, 9)] = 0.0131,
[new DateTime(2026, 5, 11)] = 0.0134,
[new DateTime(2026, 5, 12)] = 0.013,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 14)] = 0.013,
[new DateTime(2026, 5, 15)] = 0.013,
[new DateTime(2026, 5, 18)] = 0.0132,
[new DateTime(2026, 5, 19)] = 0.0131
});
var previousEod = new eod_swap_position
{
id = 70072,
SwapTradeId = td.id,
PositionId = position.id,
ValueDate = new DateTime(2026, 5, 10),
TdInterestPrincipal = 303324019.3183441374m,
InterestIncomeSum = 266674.349853521170m,
InterestProfitSum = 266674.349853521170m,
FloatRate = 0.0139m
};
var partial = service.GetInterests(
td, td.trade_extend, partialCloseDate, partialCloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
notional, notional, 0m, partialNotional, partialPercent,
(int)SwapEventTypeEnum.平仓, false, false, 0m, notional,
settment: false).Single();
AssertDecimal(84090.95m, Math.Round(partial.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero),
"GLMS-20260421-0007 30% 复利合约名义本金平仓必须按比例结算");
var final = service.GetInterests(
td, td.trade_extend, maturityDate, maturityDate,
new List<eod_swap_position>(), new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum.平仓, false, false, 0m, remainingNotional,
settment: false, newCalcLast: true).Single();
AssertDecimal(268428.73m, Math.Round(final.InterestAmount, ConsGlobal.MoneyRound,
MidpointRounding.AwayFromZero),
"后续全平必须只结算剩余70%本金的复利,不重复结算原始全额");
Assert.AreNotEqual(280303.16m,
Math.Round(final.InterestAmount, ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero),
"后续全平不得再次使用原始全额本金");
}
[TestMethod]
public void DI_GLMS_20260421_0007_EodPartialCloseCarriesRemainingCompoundAccrual()
{
const decimal originalNotional = 303139117.8m;
const decimal partialNotional = 90941735.34m;
const decimal remainingNotional = 212197382.46m;
const decimal spread = 0.0025m;
var partialCloseDate = new DateTime(2026, 5, 11);
var intermediateDate = new DateTime(2026, 5, 18);
var finalCloseDate = new DateTime(2026, 5, 19);
var td = new trade
{
id = 7007,
TradeNumber = "GLMS-20260421-0007-EOD",
ClientId = 999998,
TradeType = "收益互换",
TradeDate = new DateTime(2026, 4, 21),
StartDate = new DateTime(2026, 4, 21),
ExerciseDate = finalCloseDate,
TradeStatus = "确认成交",
ValidState = "Valid",
StructureType = "单标的",
QuoteCurrency = "CNY",
SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = 7007,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11",
SettlementRules = 0
})
}
};
var position = new swap_position
{
id = 70071,
SwapTradeId = td.id,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestMode = (int)InterestModeEnum.合约名义本金规模,
InterestRateDefault = spread,
InterestPrincipalFix = originalNotional,
PosiStartDate = td.StartDate.Value,
PosiMatuirityDate = finalCloseDate,
IsInitial = true,
Invalid = false,
InterestType = (int)InterestTypeEnum.复利,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = finalCloseDate, Rate = spread, Settlement = 0 }
})
};
var dealService = new StubCompoundSwapDealService(new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 21)] = 0.0132,
[new DateTime(2026, 4, 28)] = 0.0136,
[new DateTime(2026, 4, 30)] = 0.0139,
[new DateTime(2026, 5, 4)] = 0.0139,
[new DateTime(2026, 5, 5)] = 0.0139,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 11)] = 0.0134,
[new DateTime(2026, 5, 12)] = 0.013,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 19)] = 0.0131
});
var eodService = new StubEodPositionService { DealService = dealService };
var previousEod = new eod_swap_position
{
id = 70072,
SwapTradeId = td.id,
PositionId = position.id,
ValueDate = new DateTime(2026, 5, 10),
InterestDirection = position.InterestDirection,
InterestMode = position.InterestMode,
InterestType = position.InterestType,
InterestRateDefault = spread,
InterestIncomeSum = 266674.349853521170m,
InterestProfitSum = 266674.349853521170m,
TdInterestPrincipal = 303324019.3183441374m,
PosiNotionalValue = originalNotional,
FloatRate = 0.0139m,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0
};
var partialCloseFlow = new swap_flow_event
{
SwapTradeId = td.id,
PositionId = position.id,
EventType = (int)SwapFlowEventTypeEnum.平仓,
EventDate = partialCloseDate,
UnwindDate = partialCloseDate,
InterestDirection = position.InterestDirection,
InterestRate = spread,
InterestPrincipal = partialNotional,
InterestAmount = 84090.95m,
InterestClosePnL = 84090.95m,
DataState = (int)SwapFlowDateStateEnum.完成
};
var partialEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
previousEod, position, td, partialCloseDate, null, remainingNotional, 0m,
new List<swap_flow_event> { partialCloseFlow }, partialNotional, false);
AssertDecimal(13628.805251563956m, partialEod.TdInterestIncome,
"5/11 EOD 当日新增复利必须按平仓前全额本金计提");
AssertDecimal(196212.205105085126m, partialEod.InterestIncomeSum,
"5/11 EOD 应保留部分平仓后的待实现复利");
AssertDecimal(previousEod.TdInterestPrincipal * (1m - partialNotional / originalNotional),
partialEod.TdInterestPrincipal,
"5/11 EOD 跨日复利本金应保留剩余70%动态本金");
var intermediateInterest = dealService.GetInterests(
td, td.trade_extend, intermediateDate, intermediateDate,
new List<eod_swap_position> { partialEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum.平仓, false, false, 0m, originalNotional,
settment: false, newCalcLast: true).Single();
Assert.IsTrue(Math.Abs(259348.386714765m - intermediateInterest.InterestAmount) <= 0.01m,
$"5/18 复利平仓应承接 5/11 日终剩余本金的累计利息 Expected approximately 259348.386714765, Actual: {intermediateInterest.InterestAmount}");
}
[DataTestMethod]
[DataRow((int)InterestModeEnum.合约名义本金规模)]
[DataRow((int)InterestModeEnum.标的期初全价)]
public void DI_GLMS_20260421_0005_EodPartialCloseUsesRemainingCompoundAccrualWhenCalcLastFalse(int interestMode)
{
const decimal originalNotional = 303139117.8m;
const decimal partialNotional = 90941735.34m;
const decimal remainingNotional = 212197382.46m;
const decimal spread = 0.0025m;
var partialCloseDate = new DateTime(2026, 5, 11);
var finalCloseDate = new DateTime(2026, 5, 19);
var td = new trade
{
id = 1828,
TradeNumber = "GLMS-20260421-0005",
ClientId = 999998,
TradeType = "收益互换",
TradeDate = new DateTime(2026, 4, 21),
StartDate = new DateTime(2026, 4, 21),
ExerciseDate = finalCloseDate,
TradeStatus = "确认成交",
ValidState = "Valid",
StructureType = "单标的",
QuoteCurrency = "CNY",
SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = 1828,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10",
SettlementRules = 0
})
}
};
var position = new swap_position
{
id = 18281,
SwapTradeId = td.id,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestMode = interestMode,
InterestRateDefault = spread,
InterestPrincipalFix = originalNotional,
PosiStartDate = td.StartDate.Value,
PosiMatuirityDate = finalCloseDate,
IsInitial = true,
Invalid = false,
InterestType = (int)InterestTypeEnum.复利,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = finalCloseDate, Rate = spread, Settlement = 0 }
})
};
var dealService = new StubCompoundSwapDealService(new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 21)] = 0.0132,
[new DateTime(2026, 4, 28)] = 0.0136,
[new DateTime(2026, 4, 30)] = 0.0139,
[new DateTime(2026, 5, 4)] = 0.0139,
[new DateTime(2026, 5, 5)] = 0.0139,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 11)] = 0.0134,
[new DateTime(2026, 5, 12)] = 0.013,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 19)] = 0.0131
});
var eodService = new StubEodPositionService { DealService = dealService };
var previousEod = new eod_swap_position
{
id = 18282,
SwapTradeId = td.id,
PositionId = position.id,
ValueDate = new DateTime(2026, 5, 10),
InterestDirection = position.InterestDirection,
InterestMode = position.InterestMode,
InterestType = position.InterestType,
InterestRateDefault = spread,
InterestIncomeSum = 266674.349853521170m,
InterestProfitSum = 266674.349853521170m,
TdInterestPrincipal = 303324019.318344137434m,
PosiNotionalValue = originalNotional,
FloatRate = 0.0139m,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0
};
var partialCloseFlow = new swap_flow_event
{
SwapTradeId = td.id,
PositionId = position.id,
EventType = (int)SwapFlowEventTypeEnum.平仓,
EventDate = partialCloseDate,
UnwindDate = partialCloseDate,
InterestDirection = position.InterestDirection,
InterestRate = spread,
InterestPrincipal = partialNotional,
InterestAmount = 80002.30m,
InterestClosePnL = 80002.30m,
DataState = (int)SwapFlowDateStateEnum.完成
};
var partialEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition(
previousEod, position, td, partialCloseDate, null, remainingNotional, 0m,
new List<swap_flow_event> { partialCloseFlow }, partialNotional, false);
AssertDecimal(9540.163676094769m, partialEod.TdInterestIncome,
"0005 计算不算尾时,部分平仓日终新增复利必须按剩余70%本金计提");
AssertDecimal(196212.213529615939m, partialEod.InterestIncomeSum,
"0005 部分平仓后日终待实现复利必须扣除实际80002.30结算");
AssertDecimal(previousEod.TdInterestPrincipal * (1m - partialNotional / originalNotional),
partialEod.TdInterestPrincipal,
"0005 部分平仓后,下一日复利本金必须只继承剩余70%本金");
var intermediateDate = new DateTime(2026, 5, 18);
var intermediateInterest = dealService.GetInterests(
td, td.trade_extend, intermediateDate, intermediateDate,
new List<eod_swap_position> { partialEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum.平仓, false, false, 0m, originalNotional,
settment: false, newCalcLast: true).Single();
Assert.IsTrue(Math.Abs(259348.386714765m - intermediateInterest.InterestAmount) <= 0.01m,
$"0005 5/18 复利应承接部分平仓后的累计利息 Expected approximately 259348.386714765, Actual: {intermediateInterest.InterestAmount}");
var intermediateEod = partialEod.Clone();
intermediateEod.id = 18283;
intermediateEod.ValueDate = intermediateDate;
intermediateEod.InterestIncomeSum = intermediateInterest.InterestAmount;
intermediateEod.InterestProfitSum = intermediateInterest.InterestAmount;
intermediateEod.TdInterestPrincipal = remainingNotional;
intermediateEod.PosiNotionalValue = remainingNotional;
var expectedEndFlow = new swap_flow_event { InterestRate = spread };
decimal expectedAmountAtEnd = 0m;
decimal expectedTdAmountAtEnd = 0m;
dealService.CalcDailyCompoundInterest(
finalCloseDate, position, remainingNotional, expectedEndFlow, AnnualDays, false,
intermediateEod.FloatRate, 1m, originalNotional, true, false,
ref expectedAmountAtEnd, ref expectedTdAmountAtEnd);
var expectedPreviousFlow = new swap_flow_event { InterestRate = spread };
decimal expectedAmountAtPreviousEod = 0m;
decimal expectedTdAmountAtPreviousEod = 0m;
dealService.CalcDailyCompoundInterest(
intermediateDate, position, remainingNotional, expectedPreviousFlow, AnnualDays, false,
intermediateEod.FloatRate, 1m, originalNotional, true, true,
ref expectedAmountAtPreviousEod, ref expectedTdAmountAtPreviousEod);
var expectedFinalInterest = intermediateEod.InterestIncomeSum
+ expectedAmountAtEnd - expectedAmountAtPreviousEod;
var finalInterest = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { intermediateEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum.平仓, false, false, 0m, originalNotional,
settment: false, newCalcLast: false).Single();
AssertDecimal(expectedFinalInterest, finalInterest.InterestAmount,
"0005 最终全平重放时,历史5/18终点必须包含当日利息后再做差额");
}
[TestMethod]
public void DI_GLMS_20260421_0006_FinalCloseCarriesPreviousEodInterest()
{
const decimal remainingNotional = 212197382.46m;
const decimal expectedInterest = -119386.71m;
var finalCloseDate = new DateTime(2026, 5, 19);
var td = new trade
{
id = 6006,
TradeNumber = "GLMS-20260421-0006",
ClientId = 999998,
TradeType = "收益互换",
TradeDate = new DateTime(2026, 4, 21),
StartDate = new DateTime(2026, 4, 22),
ExerciseDate = finalCloseDate,
TradeStatus = "确认成交",
ValidState = "Valid",
StructureType = "单标的",
QuoteCurrency = "CNY",
SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = 6006,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10",
SettlementRules = 0
})
}
};
var position = new swap_position
{
id = 60061,
SwapTradeId = td.id,
PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestMode = (int)InterestModeEnum.标的期初全价,
InterestRateDefault = -0.021m,
InterestPrincipalFix = remainingNotional,
PosiStartDate = td.StartDate.Value,
PosiMatuirityDate = td.ExerciseDate.Value,
IsInitial = true,
Invalid = false,
InterestType = (int)InterestTypeEnum.复利,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = null
};
var previousEod = new eod_swap_position
{
id = 60062,
SwapTradeId = td.id,
PositionId = position.id,
ValueDate = new DateTime(2026, 5, 18),
InterestDirection = position.InterestDirection,
InterestMode = position.InterestMode,
InterestType = position.InterestType,
InterestRateDefault = position.InterestRateDefault,
FloatRate = 0.0132m,
InterestIncomeSum = expectedInterest,
InterestProfitSum = expectedInterest,
TdInterestPrincipal = remainingNotional,
PosiNotionalValue = remainingNotional,
IsAnnualized = true,
interest_rest_days = 7,
interest_rule = 0
};
var dealService = new StubCompoundSwapDealService(new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 22)] = 0.0132,
[new DateTime(2026, 4, 29)] = 0.0138,
[new DateTime(2026, 5, 6)] = 0.0136,
[new DateTime(2026, 5, 13)] = 0.0129,
[new DateTime(2026, 5, 19)] = 0.0131
});
var result = dealService.GetInterests(
td, td.trade_extend, finalCloseDate, finalCloseDate,
new List<eod_swap_position> { previousEod }, new List<swap_position> { position },
remainingNotional, remainingNotional, 0m, remainingNotional, 1m,
(int)SwapEventTypeEnum.平仓, false, false, 0m, remainingNotional,
settment: false).Single();
AssertDecimal(expectedInterest, result.InterestAmount,
"GLMS-20260421-0006 最终全平应承接 5/18 日终待实现利息");
Assert.AreNotEqual(-123072.67m, result.InterestAmount,
"不得回归旧库错误的 -123072.67 最终利息");
}
/// <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
}
}