EQD-6968: 补强 FR007 不算尾平仓误拦截回归套件(单利Red/数值一致性/非整倍数边界,重构为 Run 运行器)
This commit is contained in:
@@ -4,44 +4,24 @@ using YLErp.DBModels.Enums;
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// EQD-6968 FR007 不算尾平仓"上午未发布"误拦截 - RED 复现,修复后转绿(内存,不连库)
|
||||
/// EQD-6968 FR007 不算尾平仓"上午未发布"误拦截 —— 修复后回归套件(内存,不连库)。
|
||||
/// 任务编号 EQD-6968;现象报告日 2026-08-17。参照 GLMS20260703CloseInterestTest 内存 FR007 写法。
|
||||
///
|
||||
/// 设计:所有场景经单一 Run 运行器驱动真实 GetInterests 平仓利息路径;
|
||||
/// 内存 StubSwapDealService 重写 TryGetFloatRate 按日期返回 FR007(缺失即返回 false → 触发取价失败)。
|
||||
/// 覆盖两条计息路径(复利 CalcDailyCompoundInterest / 单利 CalcDailySimpleInterest)共用的修复点 BuildSegmentRates,
|
||||
/// 以及全平重放分支、非整倍数边界、数值一致性("跳过取价=沿用上一重置日利率")。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class GLMS20260817Fr007UnwindMorningTest
|
||||
{
|
||||
private sealed class StubSwapDealService : SwapDealService
|
||||
private static readonly Dictionary<DateTime, double> Fr007Market = new()
|
||||
{
|
||||
private readonly bool _includeCloseDate;
|
||||
|
||||
public StubSwapDealService(OptUserInfo optUser, bool includeCloseDate) : base(optUser)
|
||||
{
|
||||
_includeCloseDate = includeCloseDate;
|
||||
}
|
||||
|
||||
public readonly List<(DateTime RequestDate, double Rate)> FloatRateCalls = new();
|
||||
|
||||
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
|
||||
{
|
||||
rate = 0d;
|
||||
if (underlyingCode != "FR007") return false;
|
||||
|
||||
var fr007 = new Dictionary<DateTime, double>
|
||||
{
|
||||
[new DateTime(2026, 7, 6)] = 0.0142,
|
||||
[new DateTime(2026, 7, 13)] = 0.01425,
|
||||
};
|
||||
if (_includeCloseDate)
|
||||
fr007[new DateTime(2026, 7, 20)] = 0.0143;
|
||||
|
||||
if (fr007.TryGetValue(valueDate.Date, out rate))
|
||||
{
|
||||
FloatRateCalls.Add((valueDate.Date, rate));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
[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;
|
||||
@@ -49,15 +29,47 @@ namespace YLErp.Modules.SwapModule
|
||||
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 SwapDealService MakeService(bool includeCloseDate)
|
||||
private sealed class StubSwapDealService : SwapDealService
|
||||
{
|
||||
return new StubSwapDealService(
|
||||
new OptUserInfo(0, nameof(GLMS20260817Fr007UnwindMorningTest), OptUserFrom.UnitTest),
|
||||
includeCloseDate);
|
||||
private readonly HashSet<DateTime> _omit;
|
||||
private readonly double _closeRate;
|
||||
public readonly List<DateTime> PricedDates = new();
|
||||
|
||||
public StubSwapDealService(OptUserInfo optUser, IEnumerable<DateTime> omit, double closeRate = 0.0143)
|
||||
: base(optUser)
|
||||
{
|
||||
_omit = new HashSet<DateTime>(omit.Select(d => d.Date));
|
||||
_closeRate = closeRate;
|
||||
}
|
||||
|
||||
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
|
||||
{
|
||||
rate = 0d;
|
||||
if (underlyingCode != "FR007") return false;
|
||||
var map = new Dictionary<DateTime, double>(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;
|
||||
}
|
||||
}
|
||||
|
||||
private static trade CreateTrade()
|
||||
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)
|
||||
{
|
||||
var extend = new trade_extend
|
||||
{
|
||||
@@ -77,18 +89,18 @@ namespace YLErp.Modules.SwapModule
|
||||
TradeType = "债券TRS",
|
||||
TradeDate = TradeDate,
|
||||
StartDate = StartDate,
|
||||
ExerciseDate = CloseDate.AddDays(1),
|
||||
ExerciseDate = closeDate.AddDays(1),
|
||||
TradeStatus = "已平仓",
|
||||
ValidState = "Valid",
|
||||
trade_extend = extend
|
||||
};
|
||||
}
|
||||
|
||||
private static swap_position CreateBondPosition(InterestTypeEnum interestType, int interestRule = 0)
|
||||
private static swap_position BuildPosition(InterestTypeEnum interestType, DateTime closeDate, int restDays = 7)
|
||||
{
|
||||
var intervalModels = new List<IntervalModel>
|
||||
{
|
||||
new IntervalModel { Date = CloseDate, Rate = Spread, Settlement = 0 }
|
||||
new IntervalModel { Date = closeDate, Rate = Spread, Settlement = 0 }
|
||||
};
|
||||
return new swap_position
|
||||
{
|
||||
@@ -100,13 +112,13 @@ namespace YLErp.Modules.SwapModule
|
||||
InterestRateDefault = Spread,
|
||||
InterestPrincipalFix = Notional,
|
||||
PosiStartDate = StartDate,
|
||||
PosiMatuirityDate = CloseDate,
|
||||
PosiMatuirityDate = closeDate,
|
||||
IsInitial = true,
|
||||
Invalid = false,
|
||||
InterestType = (int)interestType,
|
||||
IsAnnualized = true,
|
||||
interest_rest_days = 7,
|
||||
interest_rule = interestRule,
|
||||
interest_rest_days = restDays,
|
||||
interest_rule = 0,
|
||||
FloatRateUnderlyingCode = "FR007",
|
||||
FloatRate = 0m,
|
||||
PosiNotionalValue = Notional,
|
||||
@@ -115,31 +127,13 @@ namespace YLErp.Modules.SwapModule
|
||||
};
|
||||
}
|
||||
|
||||
private swap_flow_event CalcCloseInterest(SwapDealService svc, InterestTypeEnum interestType, int interestRule = 0)
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var position = CreateBondPosition(interestType, interestRule);
|
||||
var interests = svc.GetInterests(
|
||||
td, td.trade_extend,
|
||||
CloseDate, CloseDate,
|
||||
new List<eod_swap_position>(),
|
||||
new List<swap_position> { position },
|
||||
Notional, Notional,
|
||||
1m,
|
||||
(int)SwapEventTypeEnum.平仓,
|
||||
false, Notional,
|
||||
false, settment: false, newCalcLast: false, closeList: null);
|
||||
Assert.AreEqual(1, interests.Count);
|
||||
return interests[0];
|
||||
}
|
||||
|
||||
private static eod_swap_position CreatePreEod()
|
||||
private static eod_swap_position BuildPreEod(DateTime valueDate)
|
||||
{
|
||||
return new eod_swap_position
|
||||
{
|
||||
id = 5001,
|
||||
PositionId = 1001,
|
||||
ValueDate = new DateTime(2026, 7, 13),
|
||||
ValueDate = valueDate,
|
||||
FloatRate = 0.01425m,
|
||||
InterestProfitSum = -100000m,
|
||||
TdInterestPrincipal = Notional,
|
||||
@@ -147,90 +141,141 @@ namespace YLErp.Modules.SwapModule
|
||||
};
|
||||
}
|
||||
|
||||
private swap_flow_event CalcCloseInterestEod(SwapDealService svc, InterestTypeEnum interestType, List<eod_swap_position> eodPositions)
|
||||
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)
|
||||
{
|
||||
var td = CreateTrade();
|
||||
var position = CreateBondPosition(interestType);
|
||||
var interests = svc.GetInterests(
|
||||
td, td.trade_extend,
|
||||
CloseDate, CloseDate,
|
||||
eodPositions,
|
||||
new List<swap_position> { position },
|
||||
Notional, Notional,
|
||||
1m,
|
||||
(int)SwapEventTypeEnum.平仓,
|
||||
false, Notional,
|
||||
false, settment: false, newCalcLast: false, closeList: null);
|
||||
Assert.AreEqual(1, interests.Count);
|
||||
return interests[0];
|
||||
}
|
||||
var cd = closeDate ?? CloseDate;
|
||||
var omit = new HashSet<DateTime>();
|
||||
if (omitDate.HasValue) omit.Add(omitDate.Value.Date);
|
||||
else if (!includeCloseDate) omit.Add(cd.Date);
|
||||
|
||||
/// <summary>
|
||||
/// [RED] 不算尾平仓,平仓日 FR007 未发布(内存缺失)→ 当前抛"获取不到FR007...价格"。
|
||||
/// 期望:修复后应成功返回(不抛)。当前为 RED(测试失败)。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Red_UnwindMorning_WithoutCloseDateFr007_ShouldSucceed()
|
||||
{
|
||||
var svc = MakeService(includeCloseDate: false);
|
||||
var svc = new StubSwapDealService(MakeOptUser(), omit, closeRate);
|
||||
var td = BuildTrade(cd);
|
||||
var position = BuildPosition(interestType, cd, restDays);
|
||||
var eodList = preEod == null
|
||||
? new List<eod_swap_position>()
|
||||
: new List<eod_swap_position> { preEod };
|
||||
try
|
||||
{
|
||||
var fe = CalcCloseInterest(svc, InterestTypeEnum.复利);
|
||||
Assert.IsNotNull(fe);
|
||||
Assert.IsFalse(fe.InterestAmount == 0 && fe.FloatRate == 0, "返回的利息不应全为零");
|
||||
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: false, closeList: null);
|
||||
Assert.AreEqual(1, interests.Count, "应返回恰好 1 条利息事件");
|
||||
return new Outcome { Fe = interests[0] };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StringAssert.Contains(ex.Message, "FR007");
|
||||
Assert.Fail($"RED 复现成功:不算尾平仓因平仓日 FR007 未发布被误拦截 —— {ex.Message}");
|
||||
return new Outcome { Ex = ex };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [Baseline] 同场景但提供平仓日 07-20 的 FR007 → 应成功(绿),隔离 stub/路径问题。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Baseline_WithCloseDateFr007_Succeeds()
|
||||
private static void AssertNoThrow(Outcome o, string scenario)
|
||||
{
|
||||
var svc = MakeService(includeCloseDate: true);
|
||||
var fe = CalcCloseInterest(svc, InterestTypeEnum.复利);
|
||||
Assert.IsNotNull(fe);
|
||||
Assert.IsTrue(fe.InterestAmount != 0, "提供末日 FR007 时应正常算出利息");
|
||||
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 + " 利息不应全为零");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [RED-重放] 不算尾全平(带前日日终持仓 → 走重放分支 replayEndDate=平仓日+1),平仓日 FR007 未发布 → 当前抛。
|
||||
/// 修复后(BuildSegmentRates 用 exclusionEndDate 排除真实平仓日)应成功,平仓日用上一重置日利率。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Red_FullClose_WithPreEod_WithoutCloseDateFr007_Succeeds()
|
||||
public void Red_Compound_WithoutCloseDateFr007_Succeeds()
|
||||
{
|
||||
var svc = MakeService(includeCloseDate: false);
|
||||
var preEod = CreatePreEod();
|
||||
try
|
||||
{
|
||||
var fe = CalcCloseInterestEod(svc, InterestTypeEnum.复利, new List<eod_swap_position> { preEod });
|
||||
Assert.IsNotNull(fe);
|
||||
Assert.IsFalse(fe.InterestAmount == 0 && fe.FloatRate == 0, "返回的利息不应全为零");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StringAssert.Contains(ex.Message, "FR007");
|
||||
Assert.Fail($"RED-重放 复现成功:不算尾全平因平仓日 FR007 未发布被误拦截 —— {ex.Message}");
|
||||
}
|
||||
AssertNoThrow(Run(InterestTypeEnum.复利, includeCloseDate: false), "复利-无preEod-缺平仓日");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [Baseline-重放] 同场景但提供平仓日 07-20 FR007 → 应成功(绿),隔离 stub/路径问题。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Baseline_FullClose_WithPreEod_WithCloseDateFr007_Succeeds()
|
||||
public void Baseline_Compound_WithCloseDateFr007_Succeeds()
|
||||
{
|
||||
var svc = MakeService(includeCloseDate: true);
|
||||
var preEod = CreatePreEod();
|
||||
var fe = CalcCloseInterestEod(svc, InterestTypeEnum.复利, new List<eod_swap_position> { preEod });
|
||||
Assert.IsNotNull(fe);
|
||||
Assert.IsTrue(fe.InterestAmount != 0, "提供末日 FR007 时应正常算出利息");
|
||||
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价-应成功");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user