From 6e2cae7c1f460e980297627ec4eb803c60294ec7 Mon Sep 17 00:00:00 2001 From: hjhan Date: Wed, 19 Aug 2026 11:07:10 +0800 Subject: [PATCH] =?UTF-8?q?test(swap):=20EQD-6968=20=E8=87=AA=E6=B4=BD?= =?UTF-8?q?=E5=8C=96=E5=A5=91=E7=BA=A6=E9=92=89=E6=AD=BB=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=88=A9=E7=8E=87=E7=A1=AE=E5=AE=9A=E6=80=A7?= =?UTF-8?q?+=E5=BF=AB=E7=85=A7=E5=86=8D=E5=AE=9A=E7=9B=98=E8=BD=BD?= =?UTF-8?q?=E4=BD=93+CI=5F007/008=20=E6=96=B0=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Determinism_NoTail_EventFloatRateIndependentOfPublishTime:尾日价缺(上午) vs 有(下午)两世界,金额与事件 FloatRate 完全一致(=末段已消费利率0.01425) - CloseOnly_平仓日为重置日_剩余持仓快照再定盘(+非重置日反例):经 DealInterests 真路由断言快照 FloatRate=当日新定盘(RefixCalls 计数)——载体职责显式化验收 - CalcFirst 用例去掉 closeRate 免疫 hack:priorValueDate 根治后首重置日恒取价 - CI_007 更名+断言翻转:平仓日=重置日+不算尾 → 事件利率=末段已消费利率(旧); 原断言(取新定盘)正是被替换的旧口径契约,根因注释留痕 GLMS-JIATT-20260805 - CI_008:本金结转断言保留,利率断言改为末段旧利率 验证:ConsumedInterestScenario 9/9、InterestEodTailSnapshot+Fr007 36/36、 全量 976 例 145 败与基线 diff=0 --- .../ConsumedInterestScenarioTest.cs | 63 +++++-------- .../GLMS20260817Fr007UnwindMorningTest.cs | 30 +++++- .../SwapModule/InterestEodTailSnapshotTest.cs | 92 +++++++++++++++++++ 3 files changed, 142 insertions(+), 43 deletions(-) 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 } }