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 StubSwapDealService Svc;
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, decimal floatRate = 0.01425m)
{
return new eod_swap_position
{
id = 5001,
PositionId = 1001,
ValueDate = valueDate,
FloatRate = floatRate,
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], Svc = svc };
}
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()
{
// 无日终快照时 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;
// 单利下 00 与 10 的利息差 = 恰好首日一天利息(年化 A365)。
var expectedStartDayInterest = Notional * (Spread + 0.0142m) / AnnualDays;
Assert.AreEqual(expectedStartDayInterest, w10.Fe.InterestAmount - w00.Fe.InterestAmount, 0.0000001m,
"不算头(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,
"事件利率必须与平仓时刻(尾日价发布前后)无关");
}
// ── 快照利率携带契约(carry-forward):带 preEod 时不重复取 ≤ValueDate 的重置日 ──
// fetchAfterDate=preEod.ValueDate + seed=preEod.FloatRate 是设计分工:≤上一日终的重置日
// 沿用快照携带的"截至 ValueDate 生效利率"(真实 EOD 快照由当日重置日再定盘写入),
// >上一日终的重新取价。与无日终场景的根本区别:种子有正确来源,不需要强制重取首重置日。
[TestMethod]
public void CarryForward_Simple_NoHead_PreEodAtStartCarriesFirstPeriodRate()
{
// preEod.ValueDate=开始日(7/6),FloatRate=7/6定盘0.0142(模拟开始日EOD快照的真实语义)
var o = Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "00",
closeDate: new DateTime(2026, 7, 10), preEod: BuildPreEod(StartDate, 0.0142m));
Assert.IsFalse(o.Threw, "不应抛:" + o.Ex?.Message);
Assert.AreEqual(0, o.Svc.PricedDates.Count,
"≤上一日终(7/6)的重置日不重复取价——首段利率由快照携带(carry-forward 契约)");
Assert.AreEqual(0.0142m, o.Fe.FloatRate,
"首段(也是末段)利率=快照携带的 7/6 定盘");
// 不算头不算尾:计息日 [7/7,7/10) 共 3 天 @ (spread+0.0142);单利重放含上日待实现(-100000)
Assert.AreEqual(-100000m + Notional * (Spread + 0.0142m) * 3m / AnnualDays, o.Fe.InterestAmount, 0.0000001m,
"金额=上日待实现+3天×(spread+快照利率),首段未误用种子外的任何值");
}
[TestMethod]
public void Eod_NoHead_StartDay_SnapshotRateCarriesFirstFixing()
{
// 链条起点钉死:开始日当天 EOD("00",窗口为空 interestStart=7/7>interestEnd=7/6)——
// 窗口为空时"不算尾不取价"分支不命中,走正常取价,快照 FloatRate=开始日定盘。
// 次日重放才能以 fetchAfter=开始日 + 携带利率=开始日定盘 正确续算(见上一用例)。
var o = Run(InterestTypeEnum.单利, includeCloseDate: true, calcMode: "00",
closeDate: StartDate, settment: true);
AssertNoThrow(o, "开始日EOD(00) 不应抛");
Assert.AreEqual(0.0142m, o.Fe.FloatRate,
"开始日EOD快照利率=当日(首重置日)定盘——窗口为空不触发不取价分支");
}
// ── 窗口判定语义(InitInterestDate 直测;死子句 td.StartDate>interestStart 删除后的边界钉死)──
[TestMethod]
public void WindowSemantics_NoHeadStartDay_EmptyViaFirstClause()
{
var svc = new StubSwapDealService(MakeOptUser(), new HashSet());
var td = BuildTrade(StartDate, "00"); // ExerciseDate=7/7
bool empty = svc.InitInterestDate(StartDate, null, td, tdClose: false,
out var start, out var end);
Assert.IsTrue(empty, "不算头首日:interestStart=7/7 > interestEnd=7/6 → 窗口为空(第一子句兜住)");
Assert.AreEqual(StartDate, end);
}
[TestMethod]
public void WindowSemantics_SameDaySettle_EqualDates_NotEmpty()
{
var svc = new StubSwapDealService(MakeOptUser(), new HashSet());
var td = BuildTrade(new DateTime(2026, 7, 10), "10"); // ExerciseDate=7/11
bool empty = svc.InitInterestDate(new DateTime(2026, 7, 10), new DateTime(2026, 7, 10), td, tdClose: false,
out var start, out var end);
Assert.IsFalse(empty, "当日已结息(日期相等)窗口非空——利息归零由 GetInterests closeList 净额层处理,不在此判定");
Assert.AreEqual(new DateTime(2026, 7, 10), start);
Assert.AreEqual(new DateTime(2026, 7, 10), end);
}
[TestMethod]
public void WindowSemantics_SameDaySettle_OnMaturityRollback_Empty()
{
var svc = new StubSwapDealService(MakeOptUser(), new HashSet());
var td = BuildTrade(CloseDate, "00", exerciseDate: CloseDate); // 到期日=7/20 且不算尾
bool empty = svc.InitInterestDate(CloseDate, CloseDate, td, tdClose: false,
out var start, out var end);
Assert.IsTrue(empty, "当日已结息+到期日不算尾回拨:interestStart=7/20 > interestEnd=7/19 → 窗口为空");
}
// ── 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");
}
}
}