防'不算尾放宽'误扩到依赖场景(方案一场景4): - Guard_TailCalced×2:算尾(11)+当日重置日+当日缺价 → 必须仍抛(复利/单利) - Baseline_TailCalced:算尾+当日有价 → 成功(对照) - Guard_NewCalcLast:交易10不算尾但本次平仓指定算尾 → effectiveCalcLast=true 仍拦 - Baseline_NewCalcLast:指定算尾+有价 → 成功 Run 运行器扩参:calcMode/interestRule/newCalcLast/market(本提交仅用 calcMode/newCalcLast) 验证:GLMS20260817Fr007UnwindMorningTest 17/17 内存全绿
349 lines
16 KiB
C#
349 lines
16 KiB
C#
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)+本次平仓指定算尾+当日有价 → 应成功");
|
||
}
|
||
}
|
||
}
|