diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260817Fr007UnwindMorningTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260817Fr007UnwindMorningTest.cs new file mode 100644 index 00000000..54cf7baa --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GLMS20260817Fr007UnwindMorningTest.cs @@ -0,0 +1,236 @@ +using Newtonsoft.Json; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// EQD-6968 FR007 不算尾平仓"上午未发布"误拦截 - RED 复现,修复后转绿(内存,不连库) + /// 任务编号 EQD-6968;现象报告日 2026-08-17。参照 GLMS20260703CloseInterestTest 内存 FR007 写法。 + /// + [TestClass] + public class GLMS20260817Fr007UnwindMorningTest + { + private sealed class StubSwapDealService : SwapDealService + { + private readonly bool _includeCloseDate; + + public StubSwapDealService(OptUserInfo optUser, bool includeCloseDate) : base(optUser) + { + _includeCloseDate = includeCloseDate; + } + + public readonly List<(DateTime RequestDate, double Rate)> FloatRateCalls = new(); + + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { + rate = 0d; + if (underlyingCode != "FR007") return false; + + var fr007 = new Dictionary + { + [new DateTime(2026, 7, 6)] = 0.0142, + [new DateTime(2026, 7, 13)] = 0.01425, + }; + if (_includeCloseDate) + fr007[new DateTime(2026, 7, 20)] = 0.0143; + + if (fr007.TryGetValue(valueDate.Date, out rate)) + { + FloatRateCalls.Add((valueDate.Date, rate)); + return true; + } + return false; + } + } + + private const decimal Notional = 279486108.21m; + private const int AnnualDays = 365; + private const decimal Spread = -0.0155m; + private static readonly DateTime StartDate = new(2026, 7, 6); + private static readonly DateTime TradeDate = new(2026, 7, 3); + private static readonly DateTime CloseDate = new(2026, 7, 20); + + private SwapDealService MakeService(bool includeCloseDate) + { + return new StubSwapDealService( + new OptUserInfo(0, nameof(GLMS20260817Fr007UnwindMorningTest), OptUserFrom.UnitTest), + includeCloseDate); + } + + private static trade CreateTrade() + { + var extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "10", + SettlementRules = 0 + }) + }; + return new trade + { + id = 1, + TradeNumber = "GLMS-20260817-FR007-MORNING", + ClientId = 999998, + TradeType = "债券TRS", + TradeDate = TradeDate, + StartDate = StartDate, + ExerciseDate = CloseDate.AddDays(1), + TradeStatus = "已平仓", + ValidState = "Valid", + trade_extend = extend + }; + } + + private static swap_position CreateBondPosition(InterestTypeEnum interestType, int interestRule = 0) + { + var intervalModels = new List + { + new IntervalModel { Date = CloseDate, Rate = Spread, Settlement = 0 } + }; + return new swap_position + { + id = 1001, + SwapTradeId = 1, + PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.支付, + InterestMode = (int)InterestModeEnum.标的期初全价, + InterestRateDefault = Spread, + InterestPrincipalFix = Notional, + PosiStartDate = StartDate, + PosiMatuirityDate = CloseDate, + IsInitial = true, + Invalid = false, + InterestType = (int)interestType, + IsAnnualized = true, + interest_rest_days = 7, + interest_rule = interestRule, + FloatRateUnderlyingCode = "FR007", + FloatRate = 0m, + PosiNotionalValue = Notional, + UnderlyingCode = "2500002.IB", + InterestSwapInterval = JsonConvert.SerializeObject(intervalModels) + }; + } + + private swap_flow_event CalcCloseInterest(SwapDealService svc, InterestTypeEnum interestType, int interestRule = 0) + { + var td = CreateTrade(); + var position = CreateBondPosition(interestType, interestRule); + var interests = svc.GetInterests( + td, td.trade_extend, + CloseDate, CloseDate, + new List(), + new List { position }, + Notional, Notional, + 1m, + (int)SwapEventTypeEnum.平仓, + false, Notional, + false, settment: false, newCalcLast: false, closeList: null); + Assert.AreEqual(1, interests.Count); + return interests[0]; + } + + private static eod_swap_position CreatePreEod() + { + return new eod_swap_position + { + id = 5001, + PositionId = 1001, + ValueDate = new DateTime(2026, 7, 13), + FloatRate = 0.01425m, + InterestProfitSum = -100000m, + TdInterestPrincipal = Notional, + InterestIncomeSum = -150000m + }; + } + + private swap_flow_event CalcCloseInterestEod(SwapDealService svc, InterestTypeEnum interestType, List eodPositions) + { + var td = CreateTrade(); + var position = CreateBondPosition(interestType); + var interests = svc.GetInterests( + td, td.trade_extend, + CloseDate, CloseDate, + eodPositions, + new List { position }, + Notional, Notional, + 1m, + (int)SwapEventTypeEnum.平仓, + false, Notional, + false, settment: false, newCalcLast: false, closeList: null); + Assert.AreEqual(1, interests.Count); + return interests[0]; + } + + /// + /// [RED] 不算尾平仓,平仓日 FR007 未发布(内存缺失)→ 当前抛"获取不到FR007...价格"。 + /// 期望:修复后应成功返回(不抛)。当前为 RED(测试失败)。 + /// + [TestMethod] + public void Red_UnwindMorning_WithoutCloseDateFr007_ShouldSucceed() + { + var svc = MakeService(includeCloseDate: false); + try + { + var fe = CalcCloseInterest(svc, InterestTypeEnum.复利); + Assert.IsNotNull(fe); + Assert.IsFalse(fe.InterestAmount == 0 && fe.FloatRate == 0, "返回的利息不应全为零"); + } + catch (Exception ex) + { + StringAssert.Contains(ex.Message, "FR007"); + Assert.Fail($"RED 复现成功:不算尾平仓因平仓日 FR007 未发布被误拦截 —— {ex.Message}"); + } + } + + /// + /// [Baseline] 同场景但提供平仓日 07-20 的 FR007 → 应成功(绿),隔离 stub/路径问题。 + /// + [TestMethod] + public void Baseline_WithCloseDateFr007_Succeeds() + { + var svc = MakeService(includeCloseDate: true); + var fe = CalcCloseInterest(svc, InterestTypeEnum.复利); + Assert.IsNotNull(fe); + Assert.IsTrue(fe.InterestAmount != 0, "提供末日 FR007 时应正常算出利息"); + } + + /// + /// [RED-重放] 不算尾全平(带前日日终持仓 → 走重放分支 replayEndDate=平仓日+1),平仓日 FR007 未发布 → 当前抛。 + /// 修复后(BuildSegmentRates 用 exclusionEndDate 排除真实平仓日)应成功,平仓日用上一重置日利率。 + /// + [TestMethod] + public void Red_FullClose_WithPreEod_WithoutCloseDateFr007_Succeeds() + { + var svc = MakeService(includeCloseDate: false); + var preEod = CreatePreEod(); + try + { + var fe = CalcCloseInterestEod(svc, InterestTypeEnum.复利, new List { preEod }); + Assert.IsNotNull(fe); + Assert.IsFalse(fe.InterestAmount == 0 && fe.FloatRate == 0, "返回的利息不应全为零"); + } + catch (Exception ex) + { + StringAssert.Contains(ex.Message, "FR007"); + Assert.Fail($"RED-重放 复现成功:不算尾全平因平仓日 FR007 未发布被误拦截 —— {ex.Message}"); + } + } + + /// + /// [Baseline-重放] 同场景但提供平仓日 07-20 FR007 → 应成功(绿),隔离 stub/路径问题。 + /// + [TestMethod] + public void Baseline_FullClose_WithPreEod_WithCloseDateFr007_Succeeds() + { + var svc = MakeService(includeCloseDate: true); + var preEod = CreatePreEod(); + var fe = CalcCloseInterestEod(svc, InterestTypeEnum.复利, new List { preEod }); + Assert.IsNotNull(fe); + Assert.IsTrue(fe.InterestAmount != 0, "提供末日 FR007 时应正常算出利息"); + } + } +} diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 229f4c1b..f720dd8a 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -475,7 +475,6 @@ namespace YLErp.Modules.SwapModule 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) @@ -696,7 +695,7 @@ namespace YLErp.Modules.SwapModule closePrincipal = closePosiNotionalValue; } - decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, swap, positionClone); + decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, swap, positionClone, calcLast || newCalcLast); // 根据场景计算利息 if (settment) @@ -847,11 +846,12 @@ namespace YLErp.Modules.SwapModule /// /// 获取浮动利率 /// - private decimal GetFloatRate(swap_position position, eod_swap_position preEod, DateTime startDate, DateTime endDate, int period, bool swap, swap_position positionClone) + private decimal GetFloatRate(swap_position position, eod_swap_position preEod, DateTime startDate, DateTime endDate, int period, bool swap, swap_position positionClone, bool calcLast = true) { if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return position.FloatRate; int days = (endDate - startDate).Days; + // 重置日恰为到期日(endDate)时,取价日=endDate;否则=startDate(原逻辑)。 DateTime rateDate = IndexFixerBase.GetFixingDate( days % period == 0 ? endDate : startDate, position.interest_rule); @@ -866,7 +866,15 @@ namespace YLErp.Modules.SwapModule position.FloatRate = positionClone.FloatRate = rate; return position.FloatRate; } - if (!swap) throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格"); + if (!swap) + { + // EQD-6968:不算尾时平仓日 FR007 缺失不拦截(该日利率不参与计息),回退期初定盘;算尾(calcLast=true)仍抛(正确依赖)。 + if (!calcLast && IndexFixer.TryGetFixing(IndexFixerBase.GetFixingDate(startDate, position.interest_rule), position.FloatRateUnderlyingCode, out decimal fb)) + return fb; + if (!calcLast) + return position.FloatRate; + throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格"); + } return 0m; } @@ -1249,7 +1257,7 @@ namespace YLErp.Modules.SwapModule // 计算截至本次平仓日的累计利息 amountAtEnd CalcDailyCompoundInterest(replayEndDate, position, closePosiNotionalValue, interestAtEnd, annualDays, floateRate, closePrecent, - calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest); + calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest, exclusionEndDate: endDate); var interestAtPreviousEod = new swap_flow_event { InterestRate = rate }; decimal amountAtPreviousEod = 0m; decimal tdAmountAtPreviousEod = 0m; @@ -1303,7 +1311,7 @@ namespace YLErp.Modules.SwapModule private (List<(DateTime StartDate, decimal Rate)> Segments, decimal LastFloat) BuildSegmentRates( DateTime startDate, DateTime endDate, int interestPeriod, swap_position position, decimal spread, decimal initialFloat, - DateTime? fetchAfterDate) + DateTime? fetchAfterDate, bool includeEnd = true, DateTime? exclusionEndDate = null) { var rates = new List<(DateTime, decimal)>(); var calcDays = (endDate - startDate).Days; @@ -1311,8 +1319,24 @@ namespace YLErp.Modules.SwapModule for (int i = 0; i <= calcDays; i += interestPeriod) { var resetDate = startDate.AddDays(i); - if (fetchAfterDate == null || resetDate > fetchAfterDate.Value) - currentFloat = ResolveFloatRate(position, resetDate, currentFloat); + bool needFetch = (fetchAfterDate == null || resetDate > fetchAfterDate.Value); + // EQD-6968:不算尾(includeEnd=false)的排除日(真实平仓日)重置——优先取新利率(满足 GLMS-JIATT-20260805 + // 重置日=平仓日用新利率),缺失则沿用上一重置日利率(该日利率不参与计息),不抛异常以免上午 FR007 未发布误拦平仓。 + bool isExcludedEnd = !includeEnd && resetDate >= (exclusionEndDate ?? endDate); + if (needFetch) + { + if (!isExcludedEnd) + { + currentFloat = ResolveFloatRate(position, resetDate, currentFloat); + } + else + { + var fixingDate = IndexFixerBase.GetFixingDate(resetDate, position.interest_rule); + if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal r)) + currentFloat = r; + // 否则沿用 currentFloat(上一重置日利率) + } + } rates.Add((resetDate, spread + currentFloat)); } return (rates, currentFloat); @@ -1331,15 +1355,16 @@ namespace YLErp.Modules.SwapModule /// public void CalcDailyCompoundInterest(DateTime endDate, swap_position position, decimal principal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, bool calcFirst, bool calcLast, - ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, decimal resetCarryInterest = 0m) + ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, decimal resetCarryInterest = 0m, DateTime? exclusionEndDate = null) { var startDate = position.PosiStartDate; int interestPeriod = position.interest_rest_days ?? 1; // 分段取率:复利全程重放,每个重置日(含 startDate)取 FR007(fetchAfterDate=null)。 + // EQD-6968:includeEnd 透传 calcLast,与下方 AccruePeriod 的边界同源,避免两处漂移。 var (segmentRates, currentFloat) = BuildSegmentRates( startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate, - fetchAfterDate: null); + fetchAfterDate: null, includeEnd: calcLast, exclusionEndDate: exclusionEndDate); // 纯函数复利计息:分段重置日并本金 + resetCarryInterest + 扣 consumedInterest var interestTrace = new AccrualTrace(); @@ -1369,7 +1394,7 @@ namespace YLErp.Modules.SwapModule /// /// 计算单利 盘中(按重置天数分段,每段使用对应浮动利率) /// - public void CalcDailySimpleInterest(eod_swap_position preEodPosition, DateTime endDate, swap_position position, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m) + public void CalcDailySimpleInterest(eod_swap_position preEodPosition, DateTime endDate, swap_position position, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, DateTime? exclusionEndDate = null) { var startDate = position.PosiStartDate; int interestPeriod = position.interest_rest_days ?? 1; @@ -1379,9 +1404,10 @@ namespace YLErp.Modules.SwapModule var accrualBasis = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv; // 分段取率:仅 ValueDate 之后的重置日才取 FR007(fetchAfterDate=ValueDate)。 + // EQD-6968:includeEnd 透传 calcLast,与下方 AccruePeriod 的边界同源,避免两处漂移。 var (segmentRates, currentFloat) = BuildSegmentRates( startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate, - fetchAfterDate: preEodPosition.ValueDate); + fetchAfterDate: preEodPosition.ValueDate, includeEnd: calcLast, exclusionEndDate: exclusionEndDate); // 纯函数计息:Accrued=缩放累计(InterestAmount),AccruedToday=未缩放累计(TdInterestAmount) var interestTrace = new AccrualTrace();