diff --git a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs index 6fa99b8e..29c1b37a 100644 --- a/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs +++ b/UnitTestProject/Modules/SwapModule/DealInterestsScenarioTest.cs @@ -115,6 +115,16 @@ namespace YLErp.Modules.SwapModule return PersistedPositions.LastOrDefault(); } + public eod_swap_position ExecuteSaveEodInterestPositionCopy( + eod_swap_position eodPayPosition, swap_position position, trade td, + DateTime valueDate, decimal posiLongNotional, decimal posiShortNotional, + decimal grossPrice, decimal orginPv) + { + SaveEodInterestPositionCopy(eodPayPosition, null, valueDate, td, position, null, + false, posiLongNotional, posiShortNotional, grossPrice, orginPv); + return PersistedPositions.LastOrDefault(); + } + // public 包装:直接调用 protected virtual DealInterests(已改为 virtual,无需反射) public void ExecuteDealInterests( List interestList, List eodPositions, @@ -258,6 +268,86 @@ namespace YLErp.Modules.SwapModule $"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}"); } + public sealed class ExcelScenario4Case + { + public string TradeNumber { get; init; } + public DateTime StartDate { get; init; } + public int SettlementRules { get; init; } + public int InterestMode { get; init; } + public int InterestType { get; init; } + public string InterestCalcMode { get; init; } + public int InterestRule { get; init; } + public decimal FixedRate { get; init; } + public decimal ExpectedPartialInterest { get; init; } + public decimal ExpectedFinalInterest { get; init; } + + public override string ToString() => TradeNumber; + } + + public static IEnumerable ExcelScenario4Cases => new List + { + ExcelCase("GLMS-20260421-0008", new DateTime(2026, 4, 22), 1, 2, 1, "11", 0, -0.021m, -37218.76m, -124093.74m), + ExcelCase("GLMS-20260421-0007", new DateTime(2026, 4, 21), 0, 2, 1, "11", 0, 0.0025m, 84090.95m, 268428.73m), + ExcelCase("GLMS-20260421-0006", new DateTime(2026, 4, 22), 1, 9, 1, "10", 0, -0.021m, -35375.54m, -119386.71m), + ExcelCase("GLMS-20260421-0005", new DateTime(2026, 4, 21), 0, 2, 1, "10", 0, 0.0025m, 80002.31m, 259348.38m), + ExcelCase("GLMS-20260421-0004", new DateTime(2026, 4, 22), 1, 9, 1, "11", -1, -0.021m, -37119.14m, -123280.17m), + ExcelCase("GLMS-20260421-0003", new DateTime(2026, 4, 21), 0, 2, 1, "11", -1, 0.0025m, 83919.92m, 269717.13m), + ExcelCase("GLMS-20260421-0002", new DateTime(2026, 4, 22), 1, 9, 1, "10", -1, -0.021m, -35350.65m, -118631.26m), + ExcelCase("GLMS-20260421-0001", new DateTime(2026, 4, 21), 0, 9, 1, "10", -1, 0.0025m, 79831.29m, 260578.53m), + ExcelCase("GLMS-20260421-0012", new DateTime(2026, 4, 22), 1, 9, 0, "11", -1, -0.021m, -37124.16m, -123307.03m), + ExcelCase("GLMS-20260421-0011", new DateTime(2026, 4, 21), 0, 2, 0, "11", -1, 0.0025m, 83894.12m, 269586.02m), + ExcelCase("GLMS-20260421-0010", new DateTime(2026, 4, 22), 1, 9, 0, "10", 0, -0.021m, -35380.07m, -119411.90m), + ExcelCase("GLMS-20260421-0009", new DateTime(2026, 4, 21), 0, 9, 0, "10", -1, 0.0025m, 79807.97m, 260458.63m) + }.Select(x => new object[] { x }); + + private static ExcelScenario4Case ExcelCase(string tradeNumber, DateTime startDate, + int settlementRules, int interestMode, int interestType, string interestCalcMode, + int interestRule, decimal fixedRate, decimal expectedPartialInterest, + decimal expectedFinalInterest) + { + return new ExcelScenario4Case + { + TradeNumber = tradeNumber, + StartDate = startDate, + SettlementRules = settlementRules, + InterestMode = interestMode, + InterestType = interestType, + InterestCalcMode = interestCalcMode, + InterestRule = interestRule, + FixedRate = fixedRate, + ExpectedPartialInterest = expectedPartialInterest, + ExpectedFinalInterest = expectedFinalInterest + }; + } + + private static IReadOnlyDictionary CreateExcelScenario4Fr007Rates() + { + return new Dictionary + { + [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, 11)] = 0.0134, + [new DateTime(2026, 5, 12)] = 0.0130, + [new DateTime(2026, 5, 13)] = 0.0129, + [new DateTime(2026, 5, 14)] = 0.0130, + [new DateTime(2026, 5, 15)] = 0.0130, + [new DateTime(2026, 5, 18)] = 0.0132, + [new DateTime(2026, 5, 19)] = 0.0131 + }; + } + #endregion // ================================================================ @@ -1753,6 +1843,154 @@ namespace YLErp.Modules.SwapModule /// [DI_MATURITY_SETTLEMENT_001] 到期日存在手动互换但未带齐待实现时不能清零; /// 当前事件按两位覆盖全部可结金额后,才可视为最终结算并清零。 /// + [DataTestMethod] + [DynamicData(nameof(ExcelScenario4Cases), DynamicDataSourceType.Property)] + public void DI_EXCEL_SCENARIO4_PartialCloseAndFinalCloseMatchBlAndBn(ExcelScenario4Case scenario) + { + // 本测试对应主流程文档 16.13 节。四个规模字段按以下恒等式变化: + // 原始本金 303139117.80 = 本次平仓 90941735.34 + 收盘后剩余 212197382.46。 + const decimal originalNotional = 303139117.80m; + const decimal partialClosePercent = 0.30m; + const decimal partialNotional = 90941735.34m; + const decimal remainingNotional = 212197382.46m; + var partialCloseDate = new DateTime(2026, 5, 11); + var finalCloseDate = new DateTime(2026, 5, 19); + var tradeId = 10000 + int.Parse(scenario.TradeNumber[^4..]); + var td = new trade + { + id = tradeId, + TradeNumber = scenario.TradeNumber, + ClientId = 999998, + TradeType = "收益互换", + TradeDate = new DateTime(2026, 4, 21), + StartDate = scenario.StartDate, + ExerciseDate = finalCloseDate, + TradeStatus = "确认成交", + ValidState = "Valid", + StructureType = "单标的", + QuoteCurrency = "CNY", + SettlementCurrency = "CNY", + trade_extend = new trade_extend + { + TradeId = tradeId, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = scenario.InterestCalcMode, + SettlementRules = scenario.SettlementRules + }) + } + }; + var position = new swap_position + { + id = tradeId * 10L + 1, + SwapTradeId = tradeId, + PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = scenario.InterestMode, + InterestRateDefault = scenario.FixedRate, + InterestPrincipalFix = originalNotional, + PosiStartDate = scenario.StartDate, + PosiMatuirityDate = finalCloseDate, + IsInitial = true, + Invalid = false, + InterestType = scenario.InterestType, + IsAnnualized = true, + interest_rest_days = 7, + interest_rule = scenario.InterestRule, + FloatRateUnderlyingCode = "FR007", + InterestSwapInterval = JsonConvert.SerializeObject(new List + { + new IntervalModel { Date = finalCloseDate, Rate = scenario.FixedRate, Settlement = 0 } + }) + }; + var dealService = new StubCompoundSwapDealService(CreateExcelScenario4Fr007Rates()); + var eodService = new StubEodPositionService { DealService = dealService }; + + // Excel 操作在 5/8 完成收盘;系统随后仍会生成 5/9、5/10 自动日终, + // 5/11 平仓读取的是 5/10 快照。漏掉周末快照会让平仓后待实现少两天全额利息。 + var preCloseEodDates = Enumerable.Range(0, + (partialCloseDate.AddDays(-1) - scenario.StartDate).Days + 1) + .Select(day => scenario.StartDate.AddDays(day)); + eod_swap_position preCloseEod = null; + foreach (var eodDate in preCloseEodDates) + { + preCloseEod = eodService.ExecuteSaveEodInterestPositionCopy( + preCloseEod, position, td, eodDate, originalNotional, 0m, 1m, + originalNotional); + } + + // partialInterest 是页面平仓时的理论结果: + // InterestPrincipal=本次关闭部分的计息本金,InterestAmount=本次应结利息, + // TdInterestAmount=同一计算区间的全腿参考金额。BL 只核对实际要结的 InterestAmount。 + // 例如 0004:InterestPrincipal=90915227.13,InterestAmount=-37119.14。 + var partialInterest = dealService.GetInterests( + td, td.trade_extend, partialCloseDate, partialCloseDate, + new List { preCloseEod }, new List { position }, + originalNotional, originalNotional, 0m, partialNotional, partialClosePercent, + (int)SwapEventTypeEnum.平仓, false, false, 1m, originalNotional, + settment: false).Single(); + AssertExcelMoney(scenario.ExpectedPartialInterest, partialInterest.InterestAmount, + $"{scenario.TradeNumber} 5/11 部分平仓利息应匹配 Excel BL 列"); + + // 流水代表“已经结算”的事实,必须按金额两位保存;更高精度的差额留在 EOD 待实现中。 + var partialCashInterest = Math.Round(partialInterest.InterestAmount, + ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero); + var partialFlow = new swap_flow_event + { + SwapTradeId = tradeId, + PositionId = position.id, + EventType = (int)SwapFlowEventTypeEnum.平仓, + EventDate = partialCloseDate, + UnwindDate = partialCloseDate, + InterestDirection = position.InterestDirection, + InterestMode = position.InterestMode, + InterestRate = partialInterest.InterestRate, + FloatRate = partialInterest.FloatRate, + InterestPrincipal = partialInterest.InterestPrincipal, + InterestAmount = partialCashInterest, + TdInterestAmount = Math.Round(partialInterest.TdInterestAmount, + ConsGlobal.MoneyRound, MidpointRounding.AwayFromZero), + InterestClosePnL = partialCashInterest, + DataState = (int)SwapFlowDateStateEnum.完成 + }; + // partialEod 是 5/11 收盘后的状态,不是流水副本。以 0004 为例: + // TdCloseInterest=-37119.14,InterestIncomeSum=-86611.313284, + // RealizedInterest=-37119.14,TdInterestPrincipal=212135529.974418。 + var partialEod = eodService.ExecuteSaveAutoEodWithCloseInterestPosition( + preCloseEod, position, td, partialCloseDate, null, remainingNotional, 0m, + new List { partialFlow }, partialNotional, false); + + // 5/11 部分平仓收盘后继续逐自然日归档到 5/18,保留剩余 70% 仓位的完整利息。 + var postCloseEodDates = Enumerable.Range(1, 7) + .Select(day => partialCloseDate.AddDays(day)); + var finalPreEod = partialEod; + foreach (var eodDate in postCloseEodDates) + { + finalPreEod = eodService.ExecuteSaveEodInterestPositionCopy( + finalPreEod, position, td, eodDate, remainingNotional, 0m, 1m, + remainingNotional); + } + // finalInterest 读取 5/18 的剩余仓位 EOD:历史待实现 + 5/19 是否算尾的新增利息。 + // 最终 closePercent=100%,因此 InterestAmount 必须带走此前部分平仓留下的全部尾差。 + var finalInterest = dealService.GetInterests( + td, td.trade_extend, finalCloseDate, finalCloseDate, + new List { finalPreEod }, new List { position }, + remainingNotional, remainingNotional, 0m, remainingNotional, 1m, + (int)SwapEventTypeEnum.平仓, false, false, 1m, remainingNotional, + settment: false).Single(); + AssertExcelMoney(scenario.ExpectedFinalInterest, finalInterest.InterestAmount, + $"{scenario.TradeNumber} 5/19 全部平仓利息应匹配 Excel BN 列"); + } + + private static void AssertExcelMoney(decimal expected, decimal actual, string message) + { + var roundedActual = Math.Round(actual, ConsGlobal.MoneyRound, + MidpointRounding.AwayFromZero); + Assert.IsTrue(Math.Abs(expected - roundedActual) <= 0.01m, + $"{message}。Expected={expected}, Actual={actual}, Rounded={roundedActual}, Diff={expected - roundedActual}"); + } + [TestMethod] public void DI_MATURITY_SETTLEMENT_001_到期手动互换仅在结清后清零() { diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 4e5792f4..7a7a0758 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -636,11 +636,14 @@ namespace YLErp.Modules.SwapModule List lastEodPositions = new SwapEodPositionService(this).GetPreEodPositions(tradeId, _preSetteDate);//上一交易数据 var posiLongNotionalValue = longPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金 var posiShortNotionalValue = shortPositions.Sum(s => s.PosiNotionalValue);// 剩余名义本金 - var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue); - var posiNotionalValue = stockEqvNotional * closePercent;//剩余名义本金 - var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional); + var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue); // 当前平仓前的实时剩余本金 + var posiNotionalValue = stockEqvNotional * closePercent;// 本次平仓名义本金 + var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional); // 上一日终的浮动端本金 var grossPrice = realPostitions.Where(x => x.PosiDirection > 0).FirstOrDefault()?.PosiGrossPrice; - var closeList = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId && x.UnwindDate == unwindDate && eventTypes.Contains(x.EventType) && x.DataState == (int)SwapFlowDateStateEnum.完成).ToList(); + var closeList = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId + && x.UnwindDate == unwindDate + && eventTypes.Contains(x.EventType) + && x.DataState == (int)SwapFlowDateStateEnum.完成).ToList(); bool tdClose = closeList.Count > 0; interests = GetInterests(td, tradeExtend, valueDate, unwindDate, lastEodPositions, positions, stockEqvNotional, posiLongNotionalValue, posiShortNotionalValue, posiNotionalValue, closePercent, eventType, tdClose, false, grossPrice ?? 0, orginPv, true, false,false, closeList); return interests; @@ -826,6 +829,7 @@ namespace YLErp.Modules.SwapModule // 计算计息区间 int interestPeriod = position.interest_rest_days ?? 1; + // true 跳过 不计利息; false 正常利息 bool swap = InitInterestDate(unwindDate, preDealDate, td, tdClose, out DateTime startDate, out DateTime endDate); // 计算名义本金 @@ -898,9 +902,9 @@ namespace YLErp.Modules.SwapModule /// private (decimal close, decimal posi, decimal closePct) CalcNotionalByMode(swap_position position, decimal closePercent, decimal posiNotional, decimal posiLong, decimal posiShort) { - decimal closePrincipal = posiNotional; - decimal posiPrincipal = posiNotional; - decimal newClosePercent = closePercent; + decimal closePrincipal = posiNotional; // 平仓部分的名义本金 + decimal posiPrincipal = posiNotional; // 持仓部分的名义本金 + decimal newClosePercent = closePercent; // 调整后的平仓比例 switch ((InterestModeEnum)position.InterestMode) { @@ -1201,10 +1205,10 @@ namespace YLErp.Modules.SwapModule if (swap) { + interest.InterestAmount = 0; // 利息金额 + interest.TdInterestAmount = 0; // 当日新增利息 interest.InterestAmount = 0; - interest.TdInterestAmount = 0; - interest.InterestAmount = 0; - interest.InterestClosePnL = 0; + interest.InterestClosePnL = 0; // 利息端平仓盈亏 } else { @@ -1218,21 +1222,28 @@ namespace YLErp.Modules.SwapModule var daysFromPreEod = preEodPosition.id != 0 ? (endDate - preEodPosition.ValueDate).Days : 0; + // 不算尾 + 当日即新周期首日 + 未到重置日 ==> 说明这一天应归入下一个计息周期 当天无需单独计息 if (!calcLast && daysFromPreEod == 1 && daysFromStart % (position.interest_rest_days ?? 1) != 0) { - interest.InterestPrincipal = preEodPosition.TdInterestPrincipal * closePrecent; + interest.InterestPrincipal = preEodPosition.TdInterestPrincipal * closePrecent; // 计息基数 interest.FloatRate = preEodPosition.FloatRate; - InterestAmount = preEodPosition.InterestIncomeSum * closePrecent; - TdInterestAmount = preEodPosition.InterestIncomeSum; + InterestAmount = preEodPosition.InterestIncomeSum * closePrecent; // 利息金额 = 待实现 * 平仓比例 + TdInterestAmount = preEodPosition.InterestIncomeSum; // 当日新增利息 interest.InterestAmount = Math.Round(InterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); interest.TdInterestAmount = Math.Round(TdInterestAmount, InterestCalculationPrecision, MidpointRounding.AwayFromZero); - interest.InterestClosePnL = interest.InterestAmount * interestRatio; + interest.InterestClosePnL = interest.InterestAmount * interestRatio; // 利息端平仓盈亏 = 利息金额 * 方向 return interest; } + // remainingPercent 只用于把上一日待实现分配给本次计算对应的本金。 + // 按照利息腿的实际 计息基数 重新计算一个历史待实现利息的 平仓比例。不替代全局的平仓比例 + // 部分平仓计算关闭 30% 时取 30%;最终全平剩余仓位时取 100%。 var remainingPercent = preEodPosition.TdInterestPrincipal > 0m ? closePosiNotionalValue / preEodPosition.TdInterestPrincipal : 1m; remainingPercent = Math.Max(0m, Math.Min(1m, remainingPercent)); + // resetCarryInterest 是重置日并入复利本金的历史待实现,不是当天新增利息。 + // 把上日尚未实现的的利息 按本次平掉的这部分计息基数分给本次平仓 并在重置日并入计息基数 + // 它只在当前 endDate 恰好为重置日时使用,避免把同一笔历史利息重复资本化。 var resetCarryInterest = preEodPosition.InterestIncomeSum * remainingPercent; CalcDailyCompoundInterest(endDate, position, closePosiNotionalValue, interest, annualDays, needPrice, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref InterestAmount, ref TdInterestAmount, @@ -1240,12 +1251,17 @@ namespace YLErp.Modules.SwapModule if (preEodPosition.id != 0 && closePrecent == 1m) { // 最终全平只重放上一日终之后的新增利息;历史部分平仓的两位结算尾差已在日终待实现中。 + // InterestAmount 是本次最终应结金额;TdInterestAmount 是不按关闭比例缩放的参考累计值。 + // 二者在全平时都以上一日 InterestIncomeSum 为起点,保证之前攒下的尾差最后一次带走。 var interestAtEnd = new swap_flow_event { InterestRate = rate }; decimal amountAtEnd = 0m; decimal tdAmountAtEnd = 0m; // InitInterestDate 在最终日不算尾时会先把 endDate 回拨一天; // 历史差分的 amountAtEnd 需补回该日,但计算器仍使用交易 calcLast, // 并将重放日期限制在合约到期日,避免提前全平或超期重复计息。 + // 如果算尾 重放日 = 正常到期日 + // 不算尾 且未超过到期日 重放日 = endDate+1 (补齐不算尾那天漏计的利息) + // 加1天超过到期日 截断到到期日 var replayEndDate = endDate; if (!calcLast && endDate < valueDate) { @@ -1255,6 +1271,7 @@ namespace YLErp.Modules.SwapModule replayEndDate = td.ExerciseDate.Value; } } + // 计算截至本次平仓日的累计利息 amountAtEnd CalcDailyCompoundInterest(replayEndDate, position, closePosiNotionalValue, interestAtEnd, annualDays, needPrice, floateRate, closePrecent, orginPv, calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest); @@ -1263,9 +1280,14 @@ namespace YLErp.Modules.SwapModule decimal tdAmountAtPreviousEod = 0m; // 最终日重放仍遵守交易的 calcLast;上一日终是历史截点而非合约尾日, // 因此此处按闭区间包含上一日终当天,避免算头不算尾时重复加入该日利息。 + // 计算截至上一日终累积的利息 amountAtPreviousEod CalcDailyCompoundInterest(preEodPosition.ValueDate, position, closePosiNotionalValue, interestAtPreviousEod, annualDays, needPrice, floateRate, closePrecent, orginPv, calcFirst, true, ref amountAtPreviousEod, ref tdAmountAtPreviousEod, consumedInterest); + // 例如 0004:5/18 待实现 -118631.261797,加 5/19 新增约 -4648.912760, + // 得到最终应结 -123280.174557,按金额两位落为 Excel BN 的 -123280.17。 + // 上一日终已保存的待实现利息 + 截至平仓日累计利息 - 截至上一日终累计利息 + // 这样只带走“上一日终以后新增的利息”,同时保留历史部分平仓时因两位金额结算留下的尾差,最终全平一次性结清。 InterestAmount = preEodPosition.InterestIncomeSum + amountAtEnd - amountAtPreviousEod; TdInterestAmount = preEodPosition.InterestIncomeSum + tdAmountAtEnd - tdAmountAtPreviousEod; } @@ -1332,6 +1354,7 @@ namespace YLErp.Modules.SwapModule { if (i % interestPeriod == 0) { + // 每个重置节点 计息基数 = 前日本金 + 本期利息 // resetCarryInterest 是上一日终待实现按本次平仓比例分摊后的存量, // 只能在 endDate 恰好是当前复利重置日时并入本金。历史重置点必须使用 // 重放到当时的 interest,否则会把上一日终存量反复注入历史本金, diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index 04505fda..adb87861 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -1269,8 +1269,13 @@ namespace YLErp.Modules.SwapModule } /// - /// 自动互换用,当日无互换,当日有平仓 + /// 将平仓/自动互换的盘中利息结果写成当日日终利息腿。 + /// 字段完整口径和逐日示例见《收益互换日终收盘总流程与当前代码审查》7.2、16.7、16.13 节。 /// + /// + /// 关键状态链:上日待实现 + 当日新增 - 当日结息 = 当日待实现; + /// 上日累计已实现 + 当日结息(按收付方向)= 当日累计已实现。 + /// /// 上一日日终持仓 /// 当前收盘日日终持仓 不可能为空 /// 利息腿信息 @@ -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:本次事件发生前理论应结的高精度利息。 + // manualSettledInterestAmount:swap_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);