fix(swap): 修复互换产品到期结算和利息计算逻辑

- 优化到期日最终结算判断逻辑,确保只有完全覆盖才清零待实现余额
- 引入利息金额舍入处理,避免精度差异导致的结算误差
- 完善自动互换流水处理,确保实际结算金额与待实现计算分离
- 修复部分平仓后日终持仓继承逻辑,保持利息累计连续性
- 调整到期自动互换处理,确保最后结算后待实现余额正确归零
- 优化利息收入和费用的独立计算,避免历史数据重复计算问题
This commit is contained in:
张名锐
2026-07-29 17:37:57 +08:00
parent a4906010a9
commit db46e48ec5
2 changed files with 249 additions and 10 deletions
@@ -42,6 +42,14 @@ namespace YLErp.Modules.SwapModule
/// </summary> /// </summary>
private sealed class StubEodPositionService : TestableSwapEodPositionService 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)) public StubEodPositionService() : base(nameof(DealInterestsScenarioTest))
{ {
} }
@@ -60,6 +68,12 @@ namespace YLErp.Modules.SwapModule
bool add = false, bool settment = true, bool newCalcLast = false, bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null) List<swap_flow_event> closeList = null)
{ {
LastInterestCalculationEodPosition = eodPositions.SingleOrDefault();
if (AutoInterests != null)
{
return AutoInterests;
}
return new SwapDealService(this).GetInterests(td, tradeExtend, valueDate, unwindDate, return new SwapDealService(this).GetInterests(td, tradeExtend, valueDate, unwindDate,
eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue,
closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice,
@@ -75,6 +89,28 @@ namespace YLErp.Modules.SwapModule
return PersistedPositions.LastOrDefault(); 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 包装:直接调用 protected virtual DealInterests(已改为 virtual,无需反射)
public void ExecuteDealInterests( public void ExecuteDealInterests(
List<swap_position> interestList, List<eod_swap_position> eodPositions, List<swap_position> interestList, List<eod_swap_position> eodPositions,
@@ -169,6 +205,27 @@ namespace YLErp.Modules.SwapModule
}; };
} }
/// <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 = "") private static void AssertDecimal(decimal expected, decimal actual, string message = "")
{ {
var tolerance = 1m / (decimal)Math.Pow(10, ConsGlobal.PriceRound - 2); var tolerance = 1m / (decimal)Math.Pow(10, ConsGlobal.PriceRound - 2);
@@ -566,6 +623,151 @@ namespace YLErp.Modules.SwapModule
#endregion #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 #endregion
} }
} }
@@ -1078,16 +1078,24 @@ namespace YLErp.Modules.SwapModule
intersetAcmount /= tradeExtend.AnnualDays; intersetAcmount /= tradeExtend.AnnualDays;
} }
newEodPayPosition.TdInterestIncome = intersetAcmount;// 要算一下当天产生的利息 newEodPayPosition.TdInterestIncome = intersetAcmount;// 要算一下当天产生的利息
if (valueDate >= td.ExerciseDate.Value) var interestIncomeBeforeSettlement = eodPayPosition.InterestIncomeSum + newEodPayPosition.TdInterestIncome;
var interestFeeBeforeSettlement = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee;
var isMaturityFinalSettlement = valueDate.Date >= td.ExerciseDate.Value.Date
&& flowEvents.Any()
&& RoundMoney(interestIncomeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterest)
&& RoundMoney(interestFeeBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterestFee);
if (isMaturityFinalSettlement)
{ {
// 当前分支已有最终收益结算事件:该事件已包含待实现余额并按两位结出,快照不再留存尾差。 // 到期日不是清零的充分条件。只有当前事件按金额两位覆盖本腿全部可结金额,
// 才能确认是最终结算;否则保留尾差,避免手工互换少结时永久丢失待实现。
newEodPayPosition.InterestIncomeSum = 0; newEodPayPosition.InterestIncomeSum = 0;
newEodPayPosition.InterestFeeSum = 0; newEodPayPosition.InterestFeeSum = 0;
} }
else else
{ {
newEodPayPosition.InterestIncomeSum = eodPayPosition.InterestIncomeSum + newEodPayPosition.TdInterestIncome - newEodPayPosition.TdCloseInterest; newEodPayPosition.InterestIncomeSum = RoundEodInterest(interestIncomeBeforeSettlement - newEodPayPosition.TdCloseInterest);
newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee; newEodPayPosition.InterestFeeSum = RoundEodInterest(interestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee);
} }
newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum;
//持仓价值 //持仓价值
@@ -1189,8 +1197,17 @@ namespace YLErp.Modules.SwapModule
orginPv = eodPayPosition.InterestPrincipalFix; orginPv = eodPayPosition.InterestPrincipalFix;
} }
var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, posiNotionalValue, closePercent, (int)SwapEventTypeEnum., false, true, grossPrice, orginPv, true); var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, posiNotionalValue, closePercent, (int)SwapEventTypeEnum., false, true, grossPrice, orginPv, true);
decimal InterestAmount = interests.Sum(x => x.InterestAmount); decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount);
decimal TdInterestAmount = interests.Sum(x => x.TdInterestAmount); decimal tdInterestAmount = interests.Sum(x => x.TdInterestAmount);
// 自动互换的流水和客户资金都由 InterestClosePnL 汇总。先把实际结算收敛到两位,
// 日终快照仍使用上面的高精度应结金额计算待实现尾差,避免把舍入差提前丢掉。
interests.ForEach(x =>
{
x.InterestAmount = RoundMoney(x.InterestAmount);
x.InterestClosePnL = RoundMoney(x.InterestClosePnL);
});
decimal settledInterestAmount = interests.Sum(x => x.InterestAmount);
newEodPayPosition.ValueDate = valueDate; newEodPayPosition.ValueDate = valueDate;
newEodPayPosition.PositionId = position.id; newEodPayPosition.PositionId = position.id;
@@ -1216,13 +1233,19 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition.TdInterestRate = interval.Rate; newEodPayPosition.TdInterestRate = interval.Rate;
//当日已实现 //当日已实现
//newEodPayPosition.TdInterestFee = 0; //newEodPayPosition.TdInterestFee = 0;
newEodPayPosition.TdCloseInterest = InterestAmount; newEodPayPosition.TdCloseInterest = settledInterestAmount;
// newEodPayPosition.TdCloseInterestFee = newEodPayPosition.TdInterestFee; // newEodPayPosition.TdCloseInterestFee = newEodPayPosition.TdInterestFee;
//持仓内容-利息腿-损益统计(本方视角) //持仓内容-利息腿-损益统计(本方视角)
newEodPayPosition.TdInterestIncome = TdInterestAmount; newEodPayPosition.TdInterestIncome = tdInterestAmount;
Log.Info($"InterestFeeSum is {eodPayPosition.InterestFeeSum},TdInterestFee is {newEodPayPosition.TdInterestFee},TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}"); Log.Info($"InterestFeeSum is {eodPayPosition.InterestFeeSum},TdInterestFee is {newEodPayPosition.TdInterestFee},TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}");
newEodPayPosition.InterestIncomeSum = 0; var isMaturityFinalAutoSettlement = valueDate.Date >= td.ExerciseDate.Value.Date;
newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee; // 到期自动互换是最后一次自动结算:两位实际金额已落流水/资金,待实现不再滚入下一日。
newEodPayPosition.InterestIncomeSum = isMaturityFinalAutoSettlement
? 0
: RoundEodInterest(interestAmountBeforeSettlement - settledInterestAmount);
newEodPayPosition.InterestFeeSum = isMaturityFinalAutoSettlement
? 0
: RoundEodInterest(eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee);
newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum; newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum;
//持仓价值 //持仓价值
newEodPayPosition.SwapPositionValue = newEodPayPosition.InterestProfitSum * ratio + newEodPayPosition.PosiProfitSum; newEodPayPosition.SwapPositionValue = newEodPayPosition.InterestProfitSum * ratio + newEodPayPosition.PosiProfitSum;
@@ -1264,12 +1287,26 @@ namespace YLErp.Modules.SwapModule
ratio = -ratio; ratio = -ratio;
} }
// 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。 // 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。
// 部分平仓仍要续接上一日日终:CalcUnwindInterest 会将 InterestProfitSum
// 加入本次待实现,已实现字段也必须按日累计,不能从新建的临时对象重新开始。
var lastInterestIncomeSum = eodPayPosition?.InterestIncomeSum ?? 0m; var lastInterestIncomeSum = eodPayPosition?.InterestIncomeSum ?? 0m;
var lastInterestFeeSum = eodPayPosition?.InterestFeeSum ?? 0m;
var lastRealizedInterest = eodPayPosition?.RealizedInterest ?? 0m;
var lastRealizedInterestFee = eodPayPosition?.RealizedInterestFee ?? 0m;
eodPayPosition = new eod_swap_position(); eodPayPosition = new eod_swap_position();
eodPayPosition.ClientId = td.ClientId; eodPayPosition.ClientId = td.ClientId;
eodPayPosition.SwapTradeId = td.id; eodPayPosition.SwapTradeId = td.id;
// CalcSwapInterests 按 PositionId 查找上一日日终;id 仍保持 0,沿用盘中平仓的原有计息日期语义。
eodPayPosition.PositionId = position.id;
eodPayPosition.PosiStartDate = td.StartDate.Value; eodPayPosition.PosiStartDate = td.StartDate.Value;
eodPayPosition.PosiMatuirityDate = td.ExerciseDate.Value; eodPayPosition.PosiMatuirityDate = td.ExerciseDate.Value;
eodPayPosition.InterestIncomeSum = lastInterestIncomeSum;
eodPayPosition.InterestFeeSum = lastInterestFeeSum;
// 盘中计息结果 InterestAmount 只承接利息待实现;费用仍由 InterestFeeSum
// 独立滚存,避免后续汇总 InterestProfitSum 时把历史费用重复计算。
eodPayPosition.InterestProfitSum = lastInterestIncomeSum;
eodPayPosition.RealizedInterest = lastRealizedInterest;
eodPayPosition.RealizedInterestFee = lastRealizedInterestFee;
if (newEodPayPosition == null) if (newEodPayPosition == null)
{ {
newEodPayPosition = eodPayPosition.Clone(); newEodPayPosition = eodPayPosition.Clone();