feat(swap): 添加收益互换利息计算功能及Excel场景测试

- 新增ExecuteSaveEodInterestPositionCopy方法用于保存日终利息持仓副本
- 添加ExcelScenario4Case类和相关测试数据用于验证利息计算准确性
- 实现DI_EXCEL_SCENARIO4_PartialCloseAndFinalCloseMatchBlAndBn测试方法
- 优化SwapDealService中的利息计算逻辑和平仓处理
- 添加详细的注释说明利息计算的核心业务规则
- 完善SwapEodPositionService中的日终持仓处理逻辑
- 修复部分平仓和最终平仓时的利息金额计算问题
This commit is contained in:
张名锐
2026-08-09 22:07:19 +08:00
parent 48e8447925
commit 2c4cd56111
3 changed files with 306 additions and 16 deletions
@@ -1269,8 +1269,13 @@ namespace YLErp.Modules.SwapModule
}
/// <summary>
/// 自动互换用,当日无互换,当日有平仓
/// 将平仓/自动互换的盘中利息结果写成当日日终利息腿。
/// 字段完整口径和逐日示例见《收益互换日终收盘总流程与当前代码审查》7.2、16.7、16.13 节。
/// </summary>
/// <remarks>
/// 关键状态链:上日待实现 + 当日新增 - 当日结息 = 当日待实现;
/// 上日累计已实现 + 当日结息(按收付方向)= 当日累计已实现。
/// </remarks>
/// <param name="eodPayPosition">上一日日终持仓</param>
/// <param name="newEodPayPosition">当前收盘日日终持仓 不可能为空</param>
/// <param name="position">利息腿信息</param>
@@ -1285,8 +1290,11 @@ namespace YLErp.Modules.SwapModule
{
Log.Info($"eodPayPosition is {JsonHelper.Serialize(eodPayPosition, false)},newEodPayPosition is {JsonHelper.Serialize(newEodPayPosition, false)}");
var tradeExtend = td.trade_extend.ExtendObj;
// oriPosiNotionalValue 是平仓前规模,posiNotionalValue 是收盘后剩余规模,closeNational 是本次关闭规模。
// 例如 30% 平仓:303139117.80 = 212197382.46 + 90941735.34。
decimal oriPosiNotionalValue = posiLongNotional + posiShortNational + closeNational;
decimal posiNotionalValue = posiLongNotional + posiShortNational;
// ratio 只负责把腿内原始金额转换为本方盈亏方向,不参与计息金额本身的计算。
decimal ratio = position.InterestDirection == (int)SwapDirectionEnum. ? 1m : -1m;//收取为正,支付为负
if (marginTypes.Contains(position.InterestMode))
{
@@ -1296,6 +1304,8 @@ namespace YLErp.Modules.SwapModule
// 部分平仓仍要续接上一日日终:CalcUnwindInterest 会将 InterestProfitSum
// 加入本次待实现,已实现字段也必须按日累计,不能从新建的临时对象重新开始。
var hasPreviousEod = eodPayPosition != null && eodPayPosition.id != 0;
// InterestIncomeSum 是尚未结算的高精度利息;RealizedInterest 是生命周期累计已结利息。
// 二者不能相互替代,也不能在部分平仓后重新从 0 开始。
var lastInterestIncomeSum = eodPayPosition?.InterestIncomeSum ?? 0m;
var lastInterestFeeSum = eodPayPosition?.InterestFeeSum ?? 0m;
var lastRealizedInterest = eodPayPosition?.RealizedInterest ?? 0m;
@@ -1332,6 +1342,7 @@ namespace YLErp.Modules.SwapModule
{
orginPv = posiNotionalValue;
}
// closePercent 描述本次关闭占平仓前仓位的比例;上例为 90941735.34 / 303139117.80 = 30%。
decimal closePercent = oriPosiNotionalValue == 0 ? 0 : closeNational / oriPosiNotionalValue;
var eventType = autoSwap ? (int)SwapEventTypeEnum. : (int)SwapEventTypeEnum.;
bool longShort = td.StructureType == ClientMarginTypeEnum..ToString();
@@ -1349,6 +1360,9 @@ namespace YLErp.Modules.SwapModule
preEodPositions.Add(eodPayPosition);
var calcLast = tradeExtend?.InterestCalcMode?.EndsWith("1") ?? true;
var interests = CalcSwapInterests(td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, posiLongNotional, posiShortNational, closeNational, 1, eventType, false, true, grossPrice, orginPv, true, settment: false, newCalcLast: autoSwap || calcLast);
// TdInterestAmount:计息器返回的全腿当日/累计参考值,用于拆出 EOD 的当日新增。
// interestAmountBeforeSettlement:本次事件发生前理论应结的高精度利息。
// manualSettledInterestAmountswap_flow_event 实际落库的手工结息,金额已按分处理。
decimal TdInterestAmount = interests.Sum(x => x.TdInterestAmount);
decimal interestAmountBeforeSettlement = interests.Sum(x => x.InterestAmount);
decimal manualSettledInterestAmount = flowEvents.Sum(x => x.InterestAmount);
@@ -1382,6 +1396,8 @@ namespace YLErp.Modules.SwapModule
newEodPayPosition.interest_rest_days = position.interest_rest_days;
newEodPayPosition.interest_rule = position.interest_rule;
//利息端估值用信息
// TdInterestPrincipal 是“下一日继续计息的收盘后本金”,不是原始合同规模,也不是本次平仓本金。
// 模式9单利直接取剩余名义本金;复利还要保留重置时已经并入本金的待实现利息。
newEodPayPosition.TdInterestPrincipal = interestModes.Contains(position.InterestMode)
? position.InterestPrincipalFix
: position.InterestMode == (int)InterestModeEnum.
@@ -1399,7 +1415,10 @@ namespace YLErp.Modules.SwapModule
//当日已实现,平仓时已处理
newEodPayPosition.TdInterestFee = flowEvents.Sum(s => s.InterestFee);
newEodPayPosition.TdCloseInterestFee = newEodPayPosition.TdInterestFee;
// TdCloseInterest 只表示当天真正结算出去的金额;部分平仓未结部分继续留在 InterestIncomeSum。
newEodPayPosition.TdCloseInterest = manualSettledInterestAmount + autoSettledInterestAmount;
// intersetAcmount 是收盘后本金的一天应计展示值。算尾部分平仓时,下面的复利分支会改用
// 平仓前全额本金重算当天新增,但跨日携带的 TdInterestPrincipal 仍只能是剩余本金。
var intersetAcmount = newEodPayPosition.TdInterestPrincipal * (newEodPayPosition.TdInterestRate + newEodPayPosition.FloatRate);
if (position.IsAnnualized)
{
@@ -1424,6 +1443,7 @@ namespace YLErp.Modules.SwapModule
// 模式2(合约名义本金规模)和模式9(标的期初全价)都以名义本金
// 作为复利基数;算尾用平仓前全额当日利息再扣实际结算,
// 不算尾只计剩余本金,避免已平部分利息进入后续复利。
// fullPrincipal 是平仓前动态复利本金,仅用于判断平仓日应按全额还是剩余额计息。
var fullPrincipal = lastTdInterestPrincipal > 0m
? lastTdInterestPrincipal
: oriPosiNotionalValue;
@@ -1438,7 +1458,9 @@ namespace YLErp.Modules.SwapModule
if (calcLast
&& position.InterestMode == (int)InterestModeEnum.)
{
// 模式2的 InterestPrincipal 是已平部分,不是跨日剩余本金。
// 模式2的 InterestPrincipal 是已平部分,需反推平仓前全额后再取剩余;
// 模式9已直接返回剩余动态本金,再反推会把 30% 平仓后的本金放大 7/3 倍。
// 例如模式9的 212135529.97 已是剩余本金,错误反推会变成 494982903.27。
newEodPayPosition.TdInterestPrincipal *= (1m - closePercent) / closePercent;
}
else if (usesFullPreviousEodPrincipal)
@@ -1472,11 +1494,15 @@ namespace YLErp.Modules.SwapModule
$",TdCloseInterest is {newEodPayPosition.TdCloseInterest}");
Log.Info($"InterestFeeSum is {eodPayPosition.InterestFeeSum},TdInterestFee is {newEodPayPosition.TdInterestFee}" +
$",TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}");
// pendingInterestBeforeSettlement 是“扣款前待实现”。普通平仓按上日待实现 + 当日新增;
// 自动互换的 interestAmountBeforeSettlement 已经是完整理论应结,不能再加一次上日值。
var pendingInterestBeforeSettlement = autoSwap
? interestAmountBeforeSettlement
: lastInterestIncomeSum + newEodPayPosition.TdInterestIncome;
var pendingInterestFeeBeforeSettlement = eodPayPosition.InterestFeeSum
+ newEodPayPosition.TdInterestFee;
// InterestIncomeSum 是收盘后仍未结算的尾差/剩余利息。
// 部分平仓:扣款前待实现 - TdCloseInterest;最终全平且两位金额已覆盖时直接清零。
newEodPayPosition.InterestIncomeSum = closePercent == 1
&& RoundMoney(pendingInterestBeforeSettlement) == RoundMoney(newEodPayPosition.TdCloseInterest)
? 0m
@@ -1486,6 +1512,7 @@ namespace YLErp.Modules.SwapModule
? 0m
: RoundEodInterest(pendingInterestFeeBeforeSettlement - newEodPayPosition.TdCloseInterestFee);
//持仓内容-利息腿-损益统计(本方视角)
// InterestProfitSum 是利息腿待实现总额,包含利息和费用;无费用时等于 InterestIncomeSum。
newEodPayPosition.InterestProfitSum = newEodPayPosition.InterestIncomeSum + newEodPayPosition.InterestFeeSum;
//持仓价值
newEodPayPosition.SwapPositionValue = newEodPayPosition.InterestProfitSum * ratio + newEodPayPosition.PosiProfitSum;
@@ -1495,6 +1522,8 @@ namespace YLErp.Modules.SwapModule
Log.Info($"InterestFeeSum is {eodPayPosition.InterestFeeSum},TdInterestFee is {newEodPayPosition.TdInterestFee}" +
$",TdCloseInterestFee is {newEodPayPosition.TdCloseInterestFee}");
//累计已实现
// RealizedInterest 只增不回滚:上日累计已实现 + 当日结息按方向后的金额。
// 收取腿的 -37119.14 会把累计已实现更新为 -37119.14;后续普通 EOD 保持该值。
newEodPayPosition.RealizedInterest = eodPayPosition.RealizedInterest + newEodPayPosition.TdCloseInterest * ratio;
newEodPayPosition.RealizedInterestFee = eodPayPosition.RealizedInterestFee + newEodPayPosition.TdCloseInterestFee;
SetFixedLegRealizedPnl(newEodPayPosition);