Files
zszq-trs/UnitTestProject/Modules/SwapModule/GLMS20260817Fr007UnwindMorningTest.cs
T
hjhan a97476c4da EQD-6968: 不算尾缺价改沿用已有利率(删期初回退) + 取价决策FIX常驻日志
① GetFloatRate 不算尾缺价不再回退取期初定盘,改沿用已有利率
   (preEod.FloatRate/position.FloatRate)——其返回值仅作 preEod.id==0 的种子
   且会被分段首段强制取价覆盖,期初取价是无消费者的多余DB读且语义误导;
② BuildSegmentRates 等价重排为 if/else-if 结构,注释改"跳过取价/沿用"准确措辞
   (行为不变:排除日有价仍取新利率,缺价沿用上一重置日利率);
③ 防历史bug"取错重置日利率"复发:GetFloatRate/ResolveFloatRate/BuildSegmentRates
   三处取价决策经 SwapCalcTrace.Critical 常驻 Info 落盘——重置日判定、取价日
   (interest_rule回拨)、命中/缺失、不算尾缺价沿用来源、排除日跳过全程可审计;
④ 测试真内存化:Stub 补 GetConsumedInterest=>0 override(复利路径原偷连96库,
   与类注释"内存,不连库"矛盾);一致性断言措辞对齐新语义;
⑤ 修复 SwapDealService.cs 文件头双 BOM 损坏。

验证:GLMS20260817Fr007UnwindMorningTest 12/12 内存全绿;
邻近套件 67过/15败与改动前基线完全一致(15败均为沙箱无96库 Connect Timeout)。
2026-08-18 08:10:30 +08:00

288 lines
13 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 保持不变),不回退取其他日期利率,不告警。
/// </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;
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;
}
// 内存世界无历史结息流水,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)
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10",
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)
{
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 = 0,
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)
{
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);
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 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)
{
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价-应成功");
}
}
}