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();