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>
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))
{
}
@@ -60,6 +68,12 @@ namespace YLErp.Modules.SwapModule
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,
@@ -75,6 +89,28 @@ namespace YLErp.Modules.SwapModule
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,
@@ -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 = "")
{
var tolerance = 1m / (decimal)Math.Pow(10, ConsGlobal.PriceRound - 2);
@@ -566,6 +623,151 @@ namespace YLErp.Modules.SwapModule
#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
}
}
@@ -1078,16 +1078,24 @@ namespace YLErp.Modules.SwapModule
intersetAcmount /= tradeExtend.AnnualDays;
}
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.InterestFeeSum = 0;
}
else
{
newEodPayPosition.InterestIncomeSum = eodPayPosition.InterestIncomeSum + newEodPayPosition.TdInterestIncome - newEodPayPosition.TdCloseInterest;
newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee;
newEodPayPosition.InterestIncomeSum = RoundEodInterest(interestIncomeBeforeSettlement - newEodPayPosition.TdCloseInterest);
newEodPayPosition.InterestFeeSum = RoundEodInterest(interestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee);
}
newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum;
//持仓价值
@@ -1189,8 +1197,17 @@ namespace YLErp.Modules.SwapModule
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);
decimal InterestAmount = interests.Sum(x => x.InterestAmount);
decimal TdInterestAmount = interests.Sum(x => x.TdInterestAmount);
decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount);
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.PositionId = position.id;
@@ -1216,13 +1233,19 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition.TdInterestRate = interval.Rate;
//当日已实现
//newEodPayPosition.TdInterestFee = 0;
newEodPayPosition.TdCloseInterest = InterestAmount;
newEodPayPosition.TdCloseInterest = settledInterestAmount;
// newEodPayPosition.TdCloseInterestFee = newEodPayPosition.TdInterestFee;
//持仓内容-利息腿-损益统计(本方视角)
newEodPayPosition.TdInterestIncome = TdInterestAmount;
newEodPayPosition.TdInterestIncome = tdInterestAmount;
Log.Info($"InterestFeeSum is {eodPayPosition.InterestFeeSum},TdInterestFee is {newEodPayPosition.TdInterestFee},TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}");
newEodPayPosition.InterestIncomeSum = 0;
newEodPayPosition.InterestFeeSum = eodPayPosition.InterestFeeSum + newEodPayPosition.TdInterestFee - newEodPayPosition.TdCloseInterestFee;
var isMaturityFinalAutoSettlement = valueDate.Date >= td.ExerciseDate.Value.Date;
// 到期自动互换是最后一次自动结算:两位实际金额已落流水/资金,待实现不再滚入下一日。
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.SwapPositionValue = newEodPayPosition.InterestProfitSum * ratio + newEodPayPosition.PosiProfitSum;
@@ -1264,12 +1287,26 @@ namespace YLErp.Modules.SwapModule
ratio = -ratio;
}
// 首次日终结算可能包含当日收盘,因此尚无先前的日终利息持仓。
// 部分平仓仍要续接上一日日终:CalcUnwindInterest 会将 InterestProfitSum
// 加入本次待实现,已实现字段也必须按日累计,不能从新建的临时对象重新开始。
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.ClientId = td.ClientId;
eodPayPosition.SwapTradeId = td.id;
// CalcSwapInterests 按 PositionId 查找上一日日终;id 仍保持 0,沿用盘中平仓的原有计息日期语义。
eodPayPosition.PositionId = position.id;
eodPayPosition.PosiStartDate = td.StartDate.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)
{
newEodPayPosition = eodPayPosition.Clone();