using Newtonsoft.Json; using YLErp.DBModels.Enums; namespace YLErp.Modules.SwapModule { /// /// EQD-6968 FR007 不算尾平仓"上午未发布"误拦截 —— 修复后回归套件(内存,不连库)。 /// 任务编号 EQD-6968;现象报告日 2026-08-17。参照 GLMS20260703CloseInterestTest 内存 FR007 写法。 /// /// 设计:所有场景经单一 Run 运行器驱动真实 GetInterests 平仓利息路径; /// 内存 StubSwapDealService 重写 TryGetFloatRate 按日期返回 FR007(缺失即返回 false → 触发取价失败)。 /// 覆盖两条计息路径(复利 CalcDailyCompoundInterest / 单利 CalcDailySimpleInterest)共用的修复点 BuildSegmentRates, /// 以及全平重放分支、非整倍数边界、数值一致性("跳过取价=沿用上一重置日利率")。 /// /// 核心语义:算头不算尾(calcLast=false)时 endDate 当天不计息,其 FR007 利率不参与计息。 /// 缺价时跳过取价(currentFloat 保持不变),不回退取其他日期利率,不告警。 /// /// 守卫矩阵(防"放宽过头",对应 EQD-6968 方案一四场景): /// Guard_*:算尾("11"或newCalcLast=true)+当日重置日+当日缺价 → 必须仍拦截(正确依赖); /// PrevBizDay_*:interest_rule=-1(前一营业日基准)→ 取价日回拨,当日未发布也放行(场景2); /// TailCalced_NonResetDay_*:算尾+当日非重置日 → 当日价未消费,缺价放行(场景3)。 /// [TestClass] public class GLMS20260817Fr007UnwindMorningTest { private static readonly Dictionary Fr007Market = new() { [new DateTime(2026, 7, 6)] = 0.0142, [new DateTime(2026, 7, 13)] = 0.01425, [new DateTime(2026, 7, 20)] = 0.0143, }; /// interest_rule=-1(前一营业日基准)取价日市场:重置日 7/6、7/13、7/20(周一) /// 经 GetFixingDate 回拨至前一营业日 7/3、7/10、7/17(周五)。 /// 同时供未回拨的 7/5、7/12、7/19(周日):QDP "chn" 日历在测试进程内可能被其他用例替换为 /// "全营业日"退化态(全量运行实测 GetNonHolidayDefore(7/19)=7/19 不回拨), /// 两套日期都供价使本套件对进程内日历状态不敏感——被测对象是取价放宽语义,不是日历本身。 private static readonly Dictionary Fr007MarketPrevBizDay = new() { [new DateTime(2026, 7, 3)] = 0.0142, [new DateTime(2026, 7, 5)] = 0.0142, [new DateTime(2026, 7, 10)] = 0.01425, [new DateTime(2026, 7, 12)] = 0.01425, [new DateTime(2026, 7, 17)] = 0.0143, [new DateTime(2026, 7, 19)] = 0.0143, }; private const double PreviousResetRate = 0.01425; 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 static readonly DateTime NonIntCloseDate = new(2026, 7, 22); private sealed class StubSwapDealService : SwapDealService { private readonly HashSet _omit; private readonly double _closeRate; private readonly Dictionary _market; public readonly List PricedDates = new(); public StubSwapDealService(OptUserInfo optUser, IEnumerable omit, double closeRate = 0.0143, Dictionary market = null) : base(optUser) { _omit = new HashSet(omit.Select(d => d.Date)); _closeRate = closeRate; _market = market; } protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) { rate = 0d; if (underlyingCode != "FR007") return false; var map = new Dictionary(_market ?? Fr007Market) { [CloseDate] = _closeRate }; if (_omit.Contains(valueDate.Date)) return false; if (map.TryGetValue(valueDate.Date, out rate)) { PricedDates.Add(valueDate.Date); return true; } return false; } // 内存世界无历史结息流水,consumedInterest=0(与本类"内存,不连库"声明一致;否则复利路径偷连 96 库) public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m; } private sealed class Outcome { public swap_flow_event Fe; public Exception Ex; public bool Threw => Ex != null; } private static OptUserInfo MakeOptUser() => new(0, nameof(GLMS20260817Fr007UnwindMorningTest), OptUserFrom.UnitTest); private static trade BuildTrade(DateTime closeDate, string calcMode = "10", DateTime? exerciseDate = null) { var extend = new trade_extend { TradeId = 1, ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson { AnnualDays = AnnualDays, InterestCalcMode = calcMode, SettlementRules = 0 }) }; return new trade { id = 1, TradeNumber = "GLMS-20260817-FR007-MORNING", ClientId = 999998, TradeType = "债券TRS", TradeDate = TradeDate, StartDate = StartDate, ExerciseDate = exerciseDate ?? closeDate.AddDays(1), TradeStatus = "已平仓", ValidState = "Valid", trade_extend = extend }; } private static swap_position BuildPosition(InterestTypeEnum interestType, DateTime closeDate, int restDays = 7, 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 = restDays, interest_rule = interestRule, FloatRateUnderlyingCode = "FR007", FloatRate = 0m, PosiNotionalValue = Notional, UnderlyingCode = "2500002.IB", InterestSwapInterval = JsonConvert.SerializeObject(intervalModels) }; } private static eod_swap_position BuildPreEod(DateTime valueDate) { return new eod_swap_position { id = 5001, PositionId = 1001, ValueDate = valueDate, FloatRate = 0.01425m, InterestProfitSum = -100000m, TdInterestPrincipal = Notional, InterestIncomeSum = -150000m }; } private static Outcome Run( InterestTypeEnum interestType, bool includeCloseDate, DateTime? closeDate = null, int restDays = 7, eod_swap_position preEod = null, decimal closePrecent = 1m, DateTime? omitDate = null, double closeRate = 0.0143, string calcMode = "10", int interestRule = 0, bool newCalcLast = false, Dictionary market = null, DateTime? omitDate2 = null, bool settment = false, DateTime? exerciseDate = null) { var cd = closeDate ?? CloseDate; var omit = new HashSet(); if (omitDate.HasValue || omitDate2.HasValue) { if (omitDate.HasValue) omit.Add(omitDate.Value.Date); if (omitDate2.HasValue) omit.Add(omitDate2.Value.Date); } else if (!includeCloseDate) omit.Add(cd.Date); var svc = new StubSwapDealService(MakeOptUser(), omit, closeRate, market); var td = BuildTrade(cd, calcMode, exerciseDate); var position = BuildPosition(interestType, cd, restDays, interestRule); var eodList = preEod == null ? new List() : new List { preEod }; try { var interests = svc.GetInterests( td, td.trade_extend, cd, cd, eodList, new List { position }, Notional, Notional, closePrecent, (int)SwapEventTypeEnum.平仓, false, Notional, false, settment: settment, newCalcLast: newCalcLast, closeList: null); Assert.AreEqual(1, interests.Count, "应返回恰好 1 条利息事件"); return new Outcome { Fe = interests[0] }; } catch (Exception ex) { return new Outcome { Ex = ex }; } } private static void AssertNoThrow(Outcome o, string scenario) { Assert.IsFalse(o.Threw, scenario + " 不应因平仓日 FR007 未发布而抛异常:" + o.Ex?.Message); Assert.IsNotNull(o.Fe, scenario + " 应返回利息事件"); Assert.IsFalse(o.Fe.InterestAmount == 0 && o.Fe.FloatRate == 0, scenario + " 利息不应全为零"); } [TestMethod] public void Red_Compound_WithoutCloseDateFr007_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: false), "复利-无preEod-缺平仓日"); } [TestMethod] public void Baseline_Compound_WithCloseDateFr007_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true), "复利-无preEod-有平仓日"); } [TestMethod] public void Red_Compound_FullClose_WithPreEod_WithoutCloseDateFr007_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: false, preEod: BuildPreEod(new DateTime(2026, 7, 13))), "复利-全平重放-缺平仓日"); } [TestMethod] public void Baseline_Compound_FullClose_WithPreEod_WithCloseDateFr007_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, preEod: BuildPreEod(new DateTime(2026, 7, 13))), "复利-全平重放-有平仓日"); } [TestMethod] public void Red_Simple_WithoutPreEod_WithoutCloseDateFr007_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.单利, includeCloseDate: false), "单利-无preEod-缺平仓日"); } [TestMethod] public void Red_Simple_WithPreEod_WithoutCloseDateFr007_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.单利, includeCloseDate: false, preEod: BuildPreEod(StartDate)), "单利-带preEod-缺平仓日"); } [TestMethod] public void Consistency_Compound_SkipEqualsPreviousRate() { var worldA = Run(InterestTypeEnum.复利, includeCloseDate: false); var worldB = Run(InterestTypeEnum.复利, includeCloseDate: true, closeRate: PreviousResetRate); Assert.IsFalse(worldA.Threw, "世界A 不应抛:" + worldA.Ex?.Message); Assert.IsFalse(worldB.Threw, "世界B 不应抛:" + worldB.Ex?.Message); Assert.AreEqual(worldA.Fe.InterestAmount, worldB.Fe.InterestAmount, "缺价跳过取价世界 应与 显式置上一期利率世界 利息完全一致(该日利率不参与计息,沿用上期)"); } [TestMethod] public void Consistency_Simple_SkipEqualsPreviousRate() { var worldA = Run(InterestTypeEnum.单利, includeCloseDate: false, preEod: BuildPreEod(StartDate)); var worldB = Run(InterestTypeEnum.单利, includeCloseDate: true, preEod: BuildPreEod(StartDate), closeRate: PreviousResetRate); Assert.IsFalse(worldA.Threw, "世界A 不应抛:" + worldA.Ex?.Message); Assert.IsFalse(worldB.Threw, "世界B 不应抛:" + worldB.Ex?.Message); Assert.AreEqual(worldA.Fe.InterestAmount, worldB.Fe.InterestAmount, "单利:缺价跳过取价世界 应与 显式置上一期利率世界 利息完全一致(该日利率不参与计息)"); } [TestMethod] public void Boundary_Compound_NonIntegerMultiple_LastResetStillPrices() { var o = Run(InterestTypeEnum.复利, includeCloseDate: false, closeDate: NonIntCloseDate, omitDate: new DateTime(2026, 7, 20)); Assert.IsTrue(o.Threw, "非整倍数时末段重置日 7/20 缺价应抛异常(该日利率被消费)"); StringAssert.Contains(o.Ex.Message, "FR007"); } [TestMethod] public void Boundary_Compound_NonIntegerMultiple_WithCloseDateSucceeds() { AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, closeDate: NonIntCloseDate), "非整倍数-有7/20价-应成功"); } [TestMethod] public void Boundary_Simple_NonIntegerMultiple_LastResetStillPrices() { var o = Run(InterestTypeEnum.单利, includeCloseDate: false, closeDate: NonIntCloseDate, preEod: BuildPreEod(StartDate), omitDate: new DateTime(2026, 7, 20)); Assert.IsTrue(o.Threw, "单利 非整倍数时末段重置日 7/20 缺价应抛异常"); StringAssert.Contains(o.Ex.Message, "FR007"); } [TestMethod] public void Boundary_Simple_NonIntegerMultiple_WithCloseDateSucceeds() { AssertNoThrow(Run(InterestTypeEnum.单利, includeCloseDate: true, closeDate: NonIntCloseDate, preEod: BuildPreEod(StartDate)), "单利-非整倍数-有7/20价-应成功"); } // ── 算尾守卫(EQD-6968 方案一场景4:算尾+当前营业日+当日重置日+当日缺价 → 必须仍拦截)── // 放宽只针对"该日利率不参与计息"的场景;算尾时当日利率被消费,缺价拦截是正确依赖,不得误放。 [TestMethod] public void Guard_TailCalced_ResetDayFr007Missing_StillThrows() { var o = Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "11", preEod: BuildPreEod(new DateTime(2026, 7, 13))); Assert.IsTrue(o.Threw, "算尾(11)+当日重置日+当日缺价 → 应拦截(该日利率被消费)"); StringAssert.Contains(o.Ex.Message, "FR007"); } [TestMethod] public void Guard_TailCalced_Simple_ResetDayFr007Missing_StillThrows() { var o = Run(InterestTypeEnum.单利, includeCloseDate: false, calcMode: "11", preEod: BuildPreEod(StartDate)); Assert.IsTrue(o.Threw, "单利 算尾(11)+当日重置日+当日缺价 → 应拦截"); StringAssert.Contains(o.Ex.Message, "FR007"); } [TestMethod] public void Baseline_TailCalced_ResetDayFr007Present_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, calcMode: "11", preEod: BuildPreEod(new DateTime(2026, 7, 13))), "算尾(11)+当日重置日+当日有价 → 应成功"); } // ── newCalcLast 守卫:交易本身"10"不算尾,但本次平仓显式指定算尾 → effectiveCalcLast=true → 缺价仍拦截 ── [TestMethod] public void Guard_NewCalcLast_OverridesToTail_MissingPrice_StillThrows() { var o = Run(InterestTypeEnum.复利, includeCloseDate: false, newCalcLast: true, preEod: BuildPreEod(new DateTime(2026, 7, 13))); Assert.IsTrue(o.Threw, "不算尾(10)+本次平仓指定算尾+当日缺价 → 应按算尾拦截"); StringAssert.Contains(o.Ex.Message, "FR007"); } [TestMethod] public void Baseline_NewCalcLast_OverridesToTail_WithPrice_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, newCalcLast: true, preEod: BuildPreEod(new DateTime(2026, 7, 13))), "不算尾(10)+本次平仓指定算尾+当日有价 → 应成功"); } // ── 前一营业日基准(interest_rule=-1,EQD-6968 方案一场景2)── // 取价日=重置日前一营业日,当日(7/20)定盘未发布也用不到 → 放行; // 但取价日(前一营业日)本身缺价 → 仍是真实依赖,必须拦截。 [TestMethod] public void PrevBizDay_TailCalced_ResetDayTodayMissing_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, calcMode: "11", interestRule: -1, omitDate: CloseDate, market: Fr007MarketPrevBizDay, preEod: BuildPreEod(new DateTime(2026, 7, 13))), "算尾(11)+前一营业日基准+当日(7/20)未发布 → 应放行(取价日7/17已发布)"); } [TestMethod] public void PrevBizDay_TailCalced_Simple_ResetDayTodayMissing_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "11", interestRule: -1, omitDate: CloseDate, market: Fr007MarketPrevBizDay, preEod: BuildPreEod(new DateTime(2026, 7, 13))), "单利 算尾(11)+前一营业日基准+当日未发布 → 应放行"); } [TestMethod] public void PrevBizDay_NoTail_ResetDayTodayMissing_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, interestRule: -1, omitDate: CloseDate, market: Fr007MarketPrevBizDay, preEod: BuildPreEod(new DateTime(2026, 7, 13))), "不算尾(10)+前一营业日基准+当日未发布 → 应放行"); } [TestMethod] public void PrevBizDay_TailCalced_FixingDayMissing_StillThrows() { // 取价日候选 7/17(周五,正常日历回拨) 与 7/19(周日,退化日历不回拨) 都扣掉 → 两种日历态下都缺价 var o = Run(InterestTypeEnum.复利, includeCloseDate: true, calcMode: "11", interestRule: -1, omitDate: new DateTime(2026, 7, 17), omitDate2: new DateTime(2026, 7, 19), market: Fr007MarketPrevBizDay, preEod: BuildPreEod(new DateTime(2026, 7, 13))); Assert.IsTrue(o.Threw, "算尾(11)+前一营业日基准+取价日本身缺价 → 仍应拦截(真实依赖)"); StringAssert.Contains(o.Ex.Message, "FR007"); } // ── 算尾+当前营业日+当日非重置日(EQD-6968 方案一场景3)── // 当日价未被任何计息段消费(末段重置日7/20是历史日),当日(7/22)缺价 → 放行。 [TestMethod] public void TailCalced_NonResetDay_TodayMissing_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: true, calcMode: "11", closeDate: NonIntCloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13))), "算尾(11)+当日非重置日+当日(7/22)缺价 → 应放行(非重置日不取当日价)"); } [TestMethod] public void TailCalced_NonResetDay_Simple_TodayMissing_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "11", closeDate: NonIntCloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13))), "单利 算尾(11)+当日非重置日+当日缺价 → 应放行"); } // ── 不算头不算尾(calcMode="00"):CalcFirst=false 组合 ── // 对尾日 FR007 行为与"10"一致(calcLast 同 false);另以单利精确断言钉 CalcFirst 语义—— // "00" 比"10"恰好少计开始日一天的利息(单利无基数效应,差值可精确到分毫)。 [TestMethod] public void Red_Compound_NoHeadNoTail_WithoutCloseDateFr007_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "00", preEod: BuildPreEod(new DateTime(2026, 7, 13))), "不算头不算尾(00)+缺平仓日价 → 应放行(与10同口径,尾日不参与计息)"); } [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); 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; // 单利下 00 与 10 的利息差 = 恰好首日一天利息(年化 A365)。 var expectedStartDayInterest = Notional * (Spread + 0.0142m) / AnnualDays; Assert.AreEqual(expectedStartDayInterest, w10.Fe.InterestAmount - w00.Fe.InterestAmount, 0.0000001m, "不算头(00)应恰好少计开始日一天利息(CalcFirst 回归锚)"); } // ── EOD 收盘归档路径(settment=true,此前全套件仅覆盖盘中 settment:false)── // EOD 不取尾日价的依赖链:InitInterestDate 到期日回拨(endDate=D-1) + CalcEodInterest 的 // calcToday=false(valueDate==到期日且不算尾) 整体跳过 ByEod 重算——ByEod 的取价 // (CalcDailyCompoundInterestByEod/CalcDailySimpleInterestByEod 的 isResetDay→ResolveFloatRate) // 不看 calcLast,任何一环回归都会让到期日收盘重新索要尾日 FR007。以下三例钉死该链。 [TestMethod] public void Eod_NoTail_MaturityDayFr007Missing_Succeeds() { // 到期日=7/20(重置日)当天收盘,尾日价未发布 → 不算尾应放行(回拨+跳过重算两道闸) AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "10", exerciseDate: CloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13)), settment: true), "EOD 不算尾(10)+到期日缺价 → 应放行(尾日不参与计息,不得取价)"); } [TestMethod] public void Eod_Tail_MaturityDayFr007Missing_StillThrows() { var o = Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "11", exerciseDate: CloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13)), settment: true); Assert.IsTrue(o.Threw, "EOD 算尾(11)+到期日(重置日)缺价 → 应拦截(该日利率被消费)"); StringAssert.Contains(o.Ex.Message, "FR007"); } [TestMethod] public void Eod_MidTradeResetDayFr007Missing_StillThrows() { // 非到期日的盘中重置日:不算尾也不豁免——新利率自当日起被持续持仓消费,ByEod 必须取到 var o = Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "10", preEod: BuildPreEod(new DateTime(2026, 7, 13)), settment: true); Assert.IsTrue(o.Threw, "EOD 不算尾(10)+非到期重置日(7/20)缺价 → 仍应拦截(ByEod 取价链,真实依赖)"); StringAssert.Contains(o.Ex.Message, "FR007"); } // ── 到期日当天全平:replayEndDate=endDate+1 补计分支(relaxedFixingFromDate 的存在理由)── // 不算尾时 InitInterestDate 把 endDate 回拨一天;最终全平的历史差分重放需把窗口补回真实 // 平仓/到期日(replayEndDate=endDate+1),但该边界日的定盘经 relaxedFixingFromDate 标记为 // "有价则取/缺价跳过"——否则到期日上午全平会被尾日价误拦(EQD-6968 在到期日的镜像场景)。 [TestMethod] public void FinalClose_OnMaturityDay_NoTail_CloseDayFr007Missing_Succeeds() { AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "10", exerciseDate: CloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13))), "到期日当天全平+不算尾+到期日(重置日)缺价 → 应放行(补计重放的边界日不索取定盘)"); } [TestMethod] public void Guard_FinalClose_OnMaturityDay_Tail_CloseDayFr007Missing_StillThrows() { var o = Run(InterestTypeEnum.复利, includeCloseDate: false, calcMode: "11", exerciseDate: CloseDate, preEod: BuildPreEod(new DateTime(2026, 7, 13))); Assert.IsTrue(o.Threw, "到期日当天全平+算尾+到期日缺价 → 应拦截(该日利率被消费)"); StringAssert.Contains(o.Ex.Message, "FR007"); } } }