Files
zszq-trs/UnitTestProject/Modules/SwapModule/GLMS20260817Fr007UnwindMorningTest.cs
T
hjhan f069c38d48 test(swap): EQD-6968 补算尾依赖守卫——重置日缺价仍拦+newCalcLast覆盖算尾仍拦
防'不算尾放宽'误扩到依赖场景(方案一场景4):
- Guard_TailCalced×2:算尾(11)+当日重置日+当日缺价 → 必须仍抛(复利/单利)
- Baseline_TailCalced:算尾+当日有价 → 成功(对照)
- Guard_NewCalcLast:交易10不算尾但本次平仓指定算尾 → effectiveCalcLast=true 仍拦
- Baseline_NewCalcLast:指定算尾+有价 → 成功
Run 运行器扩参:calcMode/interestRule/newCalcLast/market(本提交仅用 calcMode/newCalcLast)

验证:GLMS20260817Fr007UnwindMorningTest 17/17 内存全绿
2026-08-19 09:26:30 +08:00

349 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Newtonsoft.Json;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 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)。
/// </summary>
[TestClass]
public class GLMS20260817Fr007UnwindMorningTest
{
private static readonly Dictionary<DateTime, double> Fr007Market = new()
{
[new DateTime(2026, 7, 6)] = 0.0142,
[new DateTime(2026, 7, 13)] = 0.01425,
[new DateTime(2026, 7, 20)] = 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<DateTime> _omit;
private readonly double _closeRate;
private readonly Dictionary<DateTime, double> _market;
public readonly List<DateTime> PricedDates = new();
public StubSwapDealService(OptUserInfo optUser, IEnumerable<DateTime> omit, double closeRate = 0.0143,
Dictionary<DateTime, double> market = null)
: base(optUser)
{
_omit = new HashSet<DateTime>(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<DateTime, double>(_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")
{
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 = 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<IntervalModel>
{
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<DateTime, double> market = null)
{
var cd = closeDate ?? CloseDate;
var omit = new HashSet<DateTime>();
if (omitDate.HasValue) omit.Add(omitDate.Value.Date);
else if (!includeCloseDate) omit.Add(cd.Date);
var svc = new StubSwapDealService(MakeOptUser(), omit, closeRate, market);
var td = BuildTrade(cd, calcMode);
var position = BuildPosition(interestType, cd, restDays, interestRule);
var eodList = preEod == null
? new List<eod_swap_position>()
: new List<eod_swap_position> { preEod };
try
{
var interests = svc.GetInterests(
td, td.trade_extend, cd, cd, eodList,
new List<swap_position> { position },
Notional, Notional, closePrecent,
(int)SwapEventTypeEnum.平仓, false, Notional,
false, settment: false, 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)+本次平仓指定算尾+当日有价 → 应成功");
}
}
}