diff --git a/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs b/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs
index 457de583..ae6a4628 100644
--- a/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs
+++ b/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs
@@ -273,45 +273,28 @@ namespace YLErp.Modules.SwapModule
}
// ================================================================
- // 场景7:复现"平仓日=重置日 + calcLast=false → 重置日跳过 FR007 取价"
+ // 场景7:平仓日=重置日 + calcLast=false 的事件利率口径(EQD-6968 自洽化后)
// ================================================================
///
- /// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过
+ /// [CI_007] 平仓日=重置日 + calcLast=false:排除日不取价,事件利率=末段已消费利率(确定性)
/// ----------------------------------------------------------------
- /// 背景(GLMS-JIATT-20260805 根因):InterestCalcMode='10'(算头不算尾,calcLast=false),
- /// CalcDailyCompoundInterest 循环里 `if(!calcLast && accrueDate==endDate) continue` 会跳过平仓日当天。
- /// 若平仓日恰好是重置日(i%period==0),这个跳过会让"重置日取新FR007"的代码块永远不执行,
- /// 沿用上一个重置周期的旧利率。
+ /// 历史(GLMS-JIATT-20260805):原缺陷是重置日取价被 calcLast 跳过 → flowEvent.FloatRate 停留旧值
+ /// → 落库后传染 EOD。当时的修复=排除日"有价则取新定盘",事件利率因而取决于平仓时刻
+ /// (上午=旧/下午=新),与金额实际使用的利率脱钩。
///
- /// 构造:PosiStartDate=4/27, ResetPeriod=3, InterestCalcMode='10'(calcLast=false)
- /// - FR007 按日期分段:5/3之前返回 rateOld=0.001,5/3及之后返回 rateNew=0.002
- /// - 对照A:平仓日=5/5(非重置日,9? 不: (5/5-4/27)=8, 8%3=2 非重置) → 不该取新值
- /// - 对照B:平仓日=5/6(重置日,(5/6-4/27)=9, 9%3=0) → 应取新值 rateNew
+ /// EQD-6968 自洽化后的新契约:
+ /// ① 已平部分:排除日一概不取价(有价也不取),事件 FloatRate=末段已消费利率(rateOld),
+ /// 与金额同源、与平仓时刻无关;
+ /// ② 剩余持仓的新周期利率:由 EOD 快照"重置日再定盘"显式获取
+ /// (InterestEodTailSnapshotTest.CloseOnly_平仓日为重置日_剩余持仓快照再定盘)。
///
- /// 修复前:5/6 重置日被 calcLast 跳过 → 取到旧 rateOld → 与 5/5 相同
- /// 修复后:5/6 重置日正常取价 → 取到 rateNew → 与 5/5 不同
- /// ----------------------------------------------------------------
- ///
- ///
- /// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过
- /// ----------------------------------------------------------------
- /// 根因(GLMS-JIATT-20260805):InterestCalcMode='10'(calcLast=false),
- /// CalcDailyCompoundInterest 循环 `if(!calcLast && accrueDate==endDate) continue` 跳过平仓日。
- /// 若平仓日=重置日,取价代码块被跳过 → flowEvent.FloatRate 停留旧值 → 落库后传染 EOD。
- ///
- /// 构造(避开周末,period=7):
- /// PosiStartDate=4/27(周一), period=7, interest_rule=0, InterestCalcMode='10'
- /// 重置日:i=0→4/27(周一), i=7→5/4(周一,工作日)
- /// 平仓日=5/4(=重置日=endDate)
- /// FR007 分界:rateDate>=5/4 返回 rateNew,否则 rateOld
- ///
- /// 修复前:i=7(5/4)被 calcLast 跳过 → FloatRate=rateOld(旧值)
- /// 修复后:i=7(5/4)正常取价 → FloatRate=rateNew(新值)
+ /// 构造(避开周末,period=7):PosiStartDate=6/1(周一), 平仓日=6/8(周一,重置日,7%7=0);
+ /// FR007 分界:取价日>=6/8 返回 rateNew,否则 rateOld。
/// ----------------------------------------------------------------
///
[TestMethod]
- public void CI_007_平仓日等于重置日_calcLast_false_仍应取新FR007()
+ public void CI_007_平仓日等于重置日_calcLast_false_事件利率为末段已消费利率()
{
const double rateOld = 0.001;
const double rateNew = 0.002;
@@ -363,12 +346,14 @@ namespace YLErp.Modules.SwapModule
Assert.AreEqual(1, interests.Count);
var result = interests[0];
Console.WriteLine($"6/8(重置日,周一)平仓:FloatRate={result.FloatRate} Amount={result.InterestAmount:F6}");
- Console.WriteLine($" 期望 FloatRate={rateNew}(6/8 重置日查询日=6/8工作日,应取新利率)");
+ Console.WriteLine($" 新口径期望 FloatRate={rateOld}(排除日不取价,事件利率=末段已消费利率)");
- // 核心断言:6/8 是重置日,flowEvent.FloatRate 应反映新利率 rateNew
- Assert.IsTrue(Math.Abs((result.FloatRate ?? 0) - (decimal)rateNew) < 0.0001m,
- $"平仓日=重置日时 FloatRate 应={rateNew}(取到新利率)。" +
- $"实际={result.FloatRate},若={rateOld} 说明 calcLast=false 跳过了重置日取价(GLMS-JIATT-20260805 根因)");
+ // 核心断言(EQD-6968 自洽化契约):排除日(不计息)一概不取价——即使 6/8 新定盘已发布,
+ // 事件 FloatRate 也必须是末段已消费利率 rateOld,与金额同源、与平仓时刻无关。
+ // 剩余持仓的新周期利率由 EOD 快照"重置日再定盘"显式获取(见 InterestEodTailSnapshotTest)。
+ Assert.IsTrue(Math.Abs((result.FloatRate ?? 0) - (decimal)rateOld) < 0.0001m,
+ $"排除日不取价:FloatRate 应=末段已消费利率 {rateOld}。实际={result.FloatRate}," +
+ $"若={rateNew} 说明排除日仍在取价(旧口径:记录利率取决于平仓时刻)");
var interestBeforeResetDate = Principal * (FixedRate + (decimal)rateOld) * 7m / AnnualDays;
AssertDecimal(Principal + interestBeforeResetDate, result.InterestPrincipal,
@@ -426,10 +411,12 @@ namespace YLErp.Modules.SwapModule
var remainingInterest = previousInterest * remainingPrincipal / previousPrincipal;
var expectedPrincipal = remainingPrincipal + remainingInterest;
- var expectedDailyInterest = expectedPrincipal * (fixedRate + (decimal)newFloatRate) / AnnualDays;
AssertDecimal(expectedPrincipal, result.InterestPrincipal);
- AssertDecimal(expectedDailyInterest,
- result.InterestPrincipal * (result.InterestRate + result.FloatRate.Value) / AnnualDays);
+ // EQD-6968 自洽化:排除日(平仓日=重置日)不取价,事件利率=末段已消费利率(旧)——与金额同源、
+ // 与平仓时刻无关。剩余持仓的新周期利率由 EOD 快照"重置日再定盘"显式获取
+ // (InterestEodTailSnapshotTest.CloseOnly_平仓日为重置日_剩余持仓快照再定盘)。
+ AssertDecimal((decimal)oldFloatRate, result.FloatRate.Value,
+ "排除日不取价:事件 FloatRate 应=末段已消费旧利率");
}
[TestMethod]
diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260817Fr007UnwindMorningTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260817Fr007UnwindMorningTest.cs
index c326d7fc..cd559fdb 100644
--- a/UnitTestProject/Modules/SwapModule/GLMS20260817Fr007UnwindMorningTest.cs
+++ b/UnitTestProject/Modules/SwapModule/GLMS20260817Fr007UnwindMorningTest.cs
@@ -439,11 +439,10 @@ namespace YLErp.Modules.SwapModule
[TestMethod]
public void CalcFirst_Simple_NoHeadDropsExactlyStartDayInterest()
{
- // closeRate=0.0142 与 7/6 定盘相同:使"00"首段种子利率与"10"首段取价利率一致,
- // 断言只对"少计开始日一天"敏感,免疫无日终快照时 fetchAfter=interestStart-1
- // 跳过 7/6 取价、首段用种子利率的既有取价细节。
- var w10 = Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "10", closeRate: 0.0142);
- var w00 = Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "00", closeRate: 0.0142);
+ // 无日终快照时 priorValueDate=开始日-1(首重置日 7/6 恒在取价窗内),两世界首段利率同为 7/6 定盘;
+ // "00" 比"10"恰好少计开始日一天——精确断言钉 CalcFirst 边界与首重置日取价窗。
+ var w10 = Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "10");
+ var w00 = Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "00");
Assert.IsFalse(w10.Threw, "10 不应抛:" + w10.Ex?.Message);
Assert.IsFalse(w00.Threw, "00 不应抛:" + w00.Ex?.Message);
// 开始日 7/6 属首段:all-in = spread(-0.0155) + 定盘(0.0142) = -0.0013;
@@ -453,6 +452,27 @@ namespace YLErp.Modules.SwapModule
"不算头(00)应恰好少计开始日一天利息(CalcFirst 回归锚)");
}
+ // ── 事件利率确定性(EQD-6968 自洽化):排除日不取价,事件利率=末段已消费利率 ──
+ // 同一交易同一天,尾日价缺(上午平仓) vs 有(下午平仓):金额与落库 FloatRate 必须完全一致,
+ // 杜绝"记录利率取决于点击时刻"。
+
+ [TestMethod]
+ public void Determinism_NoTail_EventFloatRateIndependentOfPublishTime()
+ {
+ var wMorning = Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "10",
+ preEod: BuildPreEod(new DateTime(2026, 7, 13)));
+ var wAfternoon = Run(InterestTypeEnum.复利, includeCloseDate: true, calcMode: "10", closeRate: 0.0199,
+ preEod: BuildPreEod(new DateTime(2026, 7, 13)));
+ Assert.IsFalse(wMorning.Threw, "上午世界不应抛:" + wMorning.Ex?.Message);
+ Assert.IsFalse(wAfternoon.Threw, "下午世界不应抛:" + wAfternoon.Ex?.Message);
+ Assert.AreEqual(wMorning.Fe.InterestAmount, wAfternoon.Fe.InterestAmount,
+ "金额不应因尾日价发布与否而变化(尾日利率零消费)");
+ Assert.AreEqual(0.01425m, wMorning.Fe.FloatRate,
+ "事件利率=末段已消费利率(7/13定盘 0.01425),非尾日价");
+ Assert.AreEqual(wMorning.Fe.FloatRate, wAfternoon.Fe.FloatRate,
+ "事件利率必须与平仓时刻(尾日价发布前后)无关");
+ }
+
// ── EOD 收盘归档路径(settment=true,此前全套件仅覆盖盘中 settment:false)──
// EOD 不取尾日价的依赖链:InitInterestDate 到期日回拨(endDate=D-1) + CalcEodInterest 的
// calcToday=false(valueDate==到期日且不算尾) 整体跳过 ByEod 重算——ByEod 的取价
diff --git a/UnitTestProject/Modules/SwapModule/InterestEodTailSnapshotTest.cs b/UnitTestProject/Modules/SwapModule/InterestEodTailSnapshotTest.cs
index 01cec7cb..30c380ac 100644
--- a/UnitTestProject/Modules/SwapModule/InterestEodTailSnapshotTest.cs
+++ b/UnitTestProject/Modules/SwapModule/InterestEodTailSnapshotTest.cs
@@ -60,6 +60,16 @@ namespace YLErp.Modules.SwapModule
return CalcResult;
}
+ /// 持仓延续腿重置日再定盘接缝:计数并返回受控新定盘(不连库)
+ public decimal RefixResult { get; set; }
+ public int RefixCalls { get; private set; }
+
+ protected override decimal ResolveOngoingResetFixing(swap_position position, DateTime valueDate)
+ {
+ RefixCalls++;
+ return RefixResult;
+ }
+
public List ExecuteDealInterests(
List interestList, List eodPositions,
DateTime settleDate, trade td, List flowEvents,
@@ -223,5 +233,87 @@ namespace YLErp.Modules.SwapModule
Assert.AreEqual(5.295342465753m, p.SwapPositionValue);
Assert.AreEqual(1.0m, p.TdCurrency);
}
+
+ #region 持仓延续腿重置日再定盘(EQD-6968 自洽化:快照利率载体)
+
+ private const decimal OldFloat = 0.01425m;
+ private const decimal NewFloat = 0.0143m;
+ /// 4/27+14:7 天周期的重置日平仓
+ private static readonly DateTime ResetSettle = StartDate.AddDays(14);
+ /// 4/27+10:非重置日平仓(10%7≠0)
+ private static readonly DateTime NonResetSettle = StartDate.AddDays(10);
+
+ private static swap_position CreateFloatLegPosition() => new()
+ {
+ id = 1001, SwapTradeId = 1, InterestDirection = (int)SwapDirectionEnum.收取,
+ InterestMode = (int)InterestModeEnum.标的期初全价, InterestRateDefault = Rate,
+ InterestPrincipalFix = Principal, PosiStartDate = StartDate,
+ PosiMatuirityDate = new DateTime(2027, 4, 27), IsInitial = true,
+ InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true,
+ interest_rest_days = 7, interest_rule = 0,
+ FloatRateUnderlyingCode = "FR007",
+ InterestSwapInterval = JsonConvert.SerializeObject(new List
+ { new IntervalModel { Date = StartDate, Rate = Rate, Settlement = 1 } })
+ };
+
+ private static eod_swap_position CreatePreEodBefore(DateTime settle, decimal accumulated) => new()
+ {
+ id = 100, PositionId = 1001, ValueDate = settle.AddDays(-1),
+ InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价,
+ InterestIncomeSum = accumulated, InterestProfitSum = accumulated,
+ InterestRateDefault = Rate, TdInterestPrincipal = Principal,
+ InterestType = (int)InterestTypeEnum.单利, IsAnnualized = true, interest_rest_days = 7
+ };
+
+ private static swap_flow_event CalcResultWithFloat(decimal floatRate)
+ {
+ var e = CreateCalcResult();
+ e.FloatRate = floatRate;
+ return e;
+ }
+
+ ///
+ /// 平仓日恰为重置日且剩余持仓>0:快照 FloatRate 必须显式再定盘为当日新定盘——
+ /// 它是后续非重置日(ByEod 沿用 preEod.FloatRate)与当日应计(intersetAcmount)的利率载体。
+ /// 排除日"纯跳过"后事件利率=末段已消费利率(OldFloat),载体职责与本步骤显式分离。
+ ///
+ [TestMethod]
+ public void CloseOnly_平仓日为重置日_剩余持仓快照再定盘()
+ {
+ var service = new TailStubService
+ {
+ CalcResult = new List { CalcResultWithFloat(OldFloat) },
+ RefixResult = NewFloat,
+ };
+ service.ExecuteDealInterests(
+ new List { CreateFloatLegPosition() },
+ new List { CreatePreEodBefore(ResetSettle, Accrued10d) },
+ ResetSettle, CreateTrade(), new List { CreateCloseEvent() },
+ Remaining, ClosedNotional, 100m, Remaining);
+
+ Assert.AreEqual(1, service.RefixCalls, "不算尾+平仓日=重置日+剩余>0:应恰好显式再定盘一次");
+ Assert.AreEqual(NewFloat, service.PersistedPositions.Single().FloatRate,
+ "剩余持仓快照利率=当日新定盘(非事件末段旧利率)");
+ }
+
+ [TestMethod]
+ public void CloseOnly_平仓日非重置日_不再定盘_快照沿用事件利率()
+ {
+ var service = new TailStubService
+ {
+ CalcResult = new List { CalcResultWithFloat(OldFloat) },
+ RefixResult = NewFloat,
+ };
+ service.ExecuteDealInterests(
+ new List { CreateFloatLegPosition() },
+ new List { CreatePreEodBefore(NonResetSettle, Accrued10d) },
+ NonResetSettle, CreateTrade(), new List { CreateCloseEvent() },
+ Remaining, ClosedNotional, 100m, Remaining);
+
+ Assert.AreEqual(0, service.RefixCalls, "非重置日平仓:无需再定盘");
+ Assert.AreEqual(OldFloat, service.PersistedPositions.Single().FloatRate,
+ "快照沿用事件末段已消费利率(周期未切换)");
+ }
+ #endregion
}
}