EQD-6968: 不算尾计息不再因平仓日FR007未发布误拦平仓(缺价回退上一重置日利率,有价仍取新利率)
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
using Newtonsoft.Json;
|
||||
using YLErp.DBModels.Enums;
|
||||
|
||||
namespace YLErp.Modules.SwapModule
|
||||
{
|
||||
/// <summary>
|
||||
/// EQD-6968 FR007 不算尾平仓"上午未发布"误拦截 - RED 复现,修复后转绿(内存,不连库)
|
||||
/// 任务编号 EQD-6968;现象报告日 2026-08-17。参照 GLMS20260703CloseInterestTest 内存 FR007 写法。
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class GLMS20260817Fr007UnwindMorningTest
|
||||
{
|
||||
private sealed class StubSwapDealService : SwapDealService
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 SwapDealService MakeService(bool includeCloseDate)
|
||||
{
|
||||
return new StubSwapDealService(
|
||||
new OptUserInfo(0, nameof(GLMS20260817Fr007UnwindMorningTest), OptUserFrom.UnitTest),
|
||||
includeCloseDate);
|
||||
}
|
||||
|
||||
private static trade CreateTrade()
|
||||
{
|
||||
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 CreateBondPosition(InterestTypeEnum interestType, 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 = 7,
|
||||
interest_rule = interestRule,
|
||||
FloatRateUnderlyingCode = "FR007",
|
||||
FloatRate = 0m,
|
||||
PosiNotionalValue = Notional,
|
||||
UnderlyingCode = "2500002.IB",
|
||||
InterestSwapInterval = JsonConvert.SerializeObject(intervalModels)
|
||||
};
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
return new eod_swap_position
|
||||
{
|
||||
id = 5001,
|
||||
PositionId = 1001,
|
||||
ValueDate = new DateTime(2026, 7, 13),
|
||||
FloatRate = 0.01425m,
|
||||
InterestProfitSum = -100000m,
|
||||
TdInterestPrincipal = Notional,
|
||||
InterestIncomeSum = -150000m
|
||||
};
|
||||
}
|
||||
|
||||
private swap_flow_event CalcCloseInterestEod(SwapDealService svc, InterestTypeEnum interestType, List<eod_swap_position> eodPositions)
|
||||
{
|
||||
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];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [RED] 不算尾平仓,平仓日 FR007 未发布(内存缺失)→ 当前抛"获取不到FR007...价格"。
|
||||
/// 期望:修复后应成功返回(不抛)。当前为 RED(测试失败)。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Red_UnwindMorning_WithoutCloseDateFr007_ShouldSucceed()
|
||||
{
|
||||
var svc = MakeService(includeCloseDate: false);
|
||||
try
|
||||
{
|
||||
var fe = CalcCloseInterest(svc, InterestTypeEnum.复利);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [Baseline] 同场景但提供平仓日 07-20 的 FR007 → 应成功(绿),隔离 stub/路径问题。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Baseline_WithCloseDateFr007_Succeeds()
|
||||
{
|
||||
var svc = MakeService(includeCloseDate: true);
|
||||
var fe = CalcCloseInterest(svc, InterestTypeEnum.复利);
|
||||
Assert.IsNotNull(fe);
|
||||
Assert.IsTrue(fe.InterestAmount != 0, "提供末日 FR007 时应正常算出利息");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [RED-重放] 不算尾全平(带前日日终持仓 → 走重放分支 replayEndDate=平仓日+1),平仓日 FR007 未发布 → 当前抛。
|
||||
/// 修复后(BuildSegmentRates 用 exclusionEndDate 排除真实平仓日)应成功,平仓日用上一重置日利率。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Red_FullClose_WithPreEod_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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [Baseline-重放] 同场景但提供平仓日 07-20 FR007 → 应成功(绿),隔离 stub/路径问题。
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Baseline_FullClose_WithPreEod_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 时应正常算出利息");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,7 +475,6 @@ namespace YLErp.Modules.SwapModule
|
||||
var stockEqvNotional = realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue); // 当前平仓前的实时剩余本金
|
||||
var posiNotionalValue = stockEqvNotional * closePercent;// 本次平仓名义本金
|
||||
var orginPv = ResolveUnwindPreviousNotional(lastEod, lastEodPositions, stockEqvNotional); // 上一日终的浮动端本金
|
||||
var grossPrice = realPostitions.Where(x => x.PosiDirection > 0).FirstOrDefault()?.PosiGrossPrice;
|
||||
var closeList = DbContext.swap_flow_event.Where(x => x.SwapTradeId == tradeId
|
||||
&& x.UnwindDate == unwindDate
|
||||
&& eventTypes.Contains(x.EventType)
|
||||
@@ -696,7 +695,7 @@ namespace YLErp.Modules.SwapModule
|
||||
closePrincipal = closePosiNotionalValue;
|
||||
}
|
||||
|
||||
decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, swap, positionClone);
|
||||
decimal floatRate = GetFloatRate(position, preEodPosition, td.StartDate.Value, endDate, interestPeriod, swap, positionClone, calcLast || newCalcLast);
|
||||
|
||||
// 根据场景计算利息
|
||||
if (settment)
|
||||
@@ -847,11 +846,12 @@ namespace YLErp.Modules.SwapModule
|
||||
/// <summary>
|
||||
/// 获取浮动利率
|
||||
/// </summary>
|
||||
private decimal GetFloatRate(swap_position position, eod_swap_position preEod, DateTime startDate, DateTime endDate, int period, bool swap, swap_position positionClone)
|
||||
private decimal GetFloatRate(swap_position position, eod_swap_position preEod, DateTime startDate, DateTime endDate, int period, bool swap, swap_position positionClone, bool calcLast = true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return position.FloatRate;
|
||||
|
||||
int days = (endDate - startDate).Days;
|
||||
// 重置日恰为到期日(endDate)时,取价日=endDate;否则=startDate(原逻辑)。
|
||||
DateTime rateDate = IndexFixerBase.GetFixingDate(
|
||||
days % period == 0 ? endDate : startDate, position.interest_rule);
|
||||
|
||||
@@ -866,7 +866,15 @@ namespace YLErp.Modules.SwapModule
|
||||
position.FloatRate = positionClone.FloatRate = rate;
|
||||
return position.FloatRate;
|
||||
}
|
||||
if (!swap) throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格");
|
||||
if (!swap)
|
||||
{
|
||||
// EQD-6968:不算尾时平仓日 FR007 缺失不拦截(该日利率不参与计息),回退期初定盘;算尾(calcLast=true)仍抛(正确依赖)。
|
||||
if (!calcLast && IndexFixer.TryGetFixing(IndexFixerBase.GetFixingDate(startDate, position.interest_rule), position.FloatRateUnderlyingCode, out decimal fb))
|
||||
return fb;
|
||||
if (!calcLast)
|
||||
return position.FloatRate;
|
||||
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格");
|
||||
}
|
||||
return 0m;
|
||||
}
|
||||
|
||||
@@ -1249,7 +1257,7 @@ namespace YLErp.Modules.SwapModule
|
||||
// 计算截至本次平仓日的累计利息 amountAtEnd
|
||||
CalcDailyCompoundInterest(replayEndDate, position, closePosiNotionalValue,
|
||||
interestAtEnd, annualDays, floateRate, closePrecent,
|
||||
calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest);
|
||||
calcFirst, calcLast, ref amountAtEnd, ref tdAmountAtEnd, consumedInterest, exclusionEndDate: endDate);
|
||||
var interestAtPreviousEod = new swap_flow_event { InterestRate = rate };
|
||||
decimal amountAtPreviousEod = 0m;
|
||||
decimal tdAmountAtPreviousEod = 0m;
|
||||
@@ -1303,7 +1311,7 @@ namespace YLErp.Modules.SwapModule
|
||||
private (List<(DateTime StartDate, decimal Rate)> Segments, decimal LastFloat) BuildSegmentRates(
|
||||
DateTime startDate, DateTime endDate, int interestPeriod,
|
||||
swap_position position, decimal spread, decimal initialFloat,
|
||||
DateTime? fetchAfterDate)
|
||||
DateTime? fetchAfterDate, bool includeEnd = true, DateTime? exclusionEndDate = null)
|
||||
{
|
||||
var rates = new List<(DateTime, decimal)>();
|
||||
var calcDays = (endDate - startDate).Days;
|
||||
@@ -1311,8 +1319,24 @@ namespace YLErp.Modules.SwapModule
|
||||
for (int i = 0; i <= calcDays; i += interestPeriod)
|
||||
{
|
||||
var resetDate = startDate.AddDays(i);
|
||||
if (fetchAfterDate == null || resetDate > fetchAfterDate.Value)
|
||||
currentFloat = ResolveFloatRate(position, resetDate, currentFloat);
|
||||
bool needFetch = (fetchAfterDate == null || resetDate > fetchAfterDate.Value);
|
||||
// EQD-6968:不算尾(includeEnd=false)的排除日(真实平仓日)重置——优先取新利率(满足 GLMS-JIATT-20260805
|
||||
// 重置日=平仓日用新利率),缺失则沿用上一重置日利率(该日利率不参与计息),不抛异常以免上午 FR007 未发布误拦平仓。
|
||||
bool isExcludedEnd = !includeEnd && resetDate >= (exclusionEndDate ?? endDate);
|
||||
if (needFetch)
|
||||
{
|
||||
if (!isExcludedEnd)
|
||||
{
|
||||
currentFloat = ResolveFloatRate(position, resetDate, currentFloat);
|
||||
}
|
||||
else
|
||||
{
|
||||
var fixingDate = IndexFixerBase.GetFixingDate(resetDate, position.interest_rule);
|
||||
if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal r))
|
||||
currentFloat = r;
|
||||
// 否则沿用 currentFloat(上一重置日利率)
|
||||
}
|
||||
}
|
||||
rates.Add((resetDate, spread + currentFloat));
|
||||
}
|
||||
return (rates, currentFloat);
|
||||
@@ -1331,15 +1355,16 @@ namespace YLErp.Modules.SwapModule
|
||||
/// <returns></returns>
|
||||
public void CalcDailyCompoundInterest(DateTime endDate, swap_position position, decimal principal, swap_flow_event flowEvent,
|
||||
int annualDays, decimal floateRate, decimal closePercent, bool calcFirst, bool calcLast,
|
||||
ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, decimal resetCarryInterest = 0m)
|
||||
ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, decimal resetCarryInterest = 0m, DateTime? exclusionEndDate = null)
|
||||
{
|
||||
var startDate = position.PosiStartDate;
|
||||
int interestPeriod = position.interest_rest_days ?? 1;
|
||||
|
||||
// 分段取率:复利全程重放,每个重置日(含 startDate)取 FR007(fetchAfterDate=null)。
|
||||
// EQD-6968:includeEnd 透传 calcLast,与下方 AccruePeriod 的边界同源,避免两处漂移。
|
||||
var (segmentRates, currentFloat) = BuildSegmentRates(
|
||||
startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate,
|
||||
fetchAfterDate: null);
|
||||
fetchAfterDate: null, includeEnd: calcLast, exclusionEndDate: exclusionEndDate);
|
||||
|
||||
// 纯函数复利计息:分段重置日并本金 + resetCarryInterest + 扣 consumedInterest
|
||||
var interestTrace = new AccrualTrace();
|
||||
@@ -1369,7 +1394,7 @@ namespace YLErp.Modules.SwapModule
|
||||
/// <summary>
|
||||
/// 计算单利 盘中(按重置天数分段,每段使用对应浮动利率)
|
||||
/// </summary>
|
||||
public void CalcDailySimpleInterest(eod_swap_position preEodPosition, DateTime endDate, swap_position position, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m)
|
||||
public void CalcDailySimpleInterest(eod_swap_position preEodPosition, DateTime endDate, swap_position position, decimal posiPrincipal, swap_flow_event flowEvent, int annualDays, decimal floateRate, decimal closePercent, decimal orginPv, bool calcFirst, bool calcLast, ref decimal InterestAmount, ref decimal TdInterestAmount, decimal consumedInterest = 0m, DateTime? exclusionEndDate = null)
|
||||
{
|
||||
var startDate = position.PosiStartDate;
|
||||
int interestPeriod = position.interest_rest_days ?? 1;
|
||||
@@ -1379,9 +1404,10 @@ namespace YLErp.Modules.SwapModule
|
||||
var accrualBasis = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv;
|
||||
|
||||
// 分段取率:仅 ValueDate 之后的重置日才取 FR007(fetchAfterDate=ValueDate)。
|
||||
// EQD-6968:includeEnd 透传 calcLast,与下方 AccruePeriod 的边界同源,避免两处漂移。
|
||||
var (segmentRates, currentFloat) = BuildSegmentRates(
|
||||
startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate,
|
||||
fetchAfterDate: preEodPosition.ValueDate);
|
||||
fetchAfterDate: preEodPosition.ValueDate, includeEnd: calcLast, exclusionEndDate: exclusionEndDate);
|
||||
|
||||
// 纯函数计息:Accrued=缩放累计(InterestAmount),AccruedToday=未缩放累计(TdInterestAmount)
|
||||
var interestTrace = new AccrualTrace();
|
||||
|
||||
Reference in New Issue
Block a user