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)。
This commit is contained in:
hjhan
2026-08-18 08:10:30 +08:00
parent fb5bbfaa99
commit a97476c4da
2 changed files with 66 additions and 24 deletions
@@ -11,6 +11,9 @@ namespace YLErp.Modules.SwapModule
/// 内存 StubSwapDealService 重写 TryGetFloatRate 按日期返回 FR007(缺失即返回 false → 触发取价失败)。
/// 覆盖两条计息路径(复利 CalcDailyCompoundInterest / 单利 CalcDailySimpleInterest)共用的修复点 BuildSegmentRates
/// 以及全平重放分支、非整倍数边界、数值一致性("跳过取价=沿用上一重置日利率")。
///
/// 核心语义:算头不算尾(calcLast=false)时 endDate 当天不计息,其 FR007 利率不参与计息。
/// 缺价时跳过取价(currentFloat 保持不变),不回退取其他日期利率,不告警。
/// </summary>
[TestClass]
public class GLMS20260817Fr007UnwindMorningTest
@@ -57,6 +60,9 @@ namespace YLErp.Modules.SwapModule
}
return false;
}
// 内存世界无历史结息流水,consumedInterest=0(与本类"内存,不连库"声明一致;否则复利路径偷连 96 库)
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) => 0m;
}
private sealed class Outcome
@@ -233,7 +239,7 @@ namespace YLErp.Modules.SwapModule
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]
@@ -244,7 +250,7 @@ namespace YLErp.Modules.SwapModule
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]
+58 -22
View File
@@ -853,31 +853,44 @@ namespace YLErp.Modules.SwapModule
if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return position.FloatRate;
int days = (endDate - startDate).Days;
bool isResetDay = days % period == 0;
// 重置日恰为到期日(endDate)时,取价日=endDate;否则=startDate(原逻辑)。
DateTime rateDate = IndexFixerBase.GetFixingDate(
days % period == 0 ? endDate : startDate, position.interest_rule);
isResetDay ? endDate : startDate, position.interest_rule);
// 历史上有"取错重置日利率"的线上 bug,取价决策必须常驻落盘(SwapCalcTrace.Critical 无条件 Info)。
SwapCalcTrace.Critical(
$"FIX GetFloatRate p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] days={days} period={period} " +
$"重置日={isResetDay} rule={position.interest_rule} 取价日={rateDate:yyyy-MM-dd} calcLast={calcLast} " +
$"preEod={(preEod.id != 0 ? $"{preEod.ValueDate:yyyy-MM-dd}:{preEod.FloatRate:P6}" : "")}");
if (preEod.id != 0 && days % period != 0)
if (preEod.id != 0 && !isResetDay)
{
SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 非重置日→沿用昨日终FloatRate={preEod.FloatRate:P6}");
position.FloatRate = positionClone.FloatRate = preEod.FloatRate;
return preEod.FloatRate;
}
if (IndexFixer.TryGetFixing(rateDate, position.FloatRateUnderlyingCode, out decimal rate))
{
SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 重置日→取{rateDate:yyyy-MM-dd}定盘={rate:P6}");
position.FloatRate = positionClone.FloatRate = rate;
return position.FloatRate;
}
if (!swap)
{
// EQD-6968:算尾(calcLast=true)时结算日 FR007 必须落表,缺失即依赖报错(既有正确行为);
// 不算尾(calcLast=false)时结算日 FR007 不强制,缺失则回退期初定盘,避免上午未发布误拦平仓。
if (calcLast)
{
SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且算尾→抛异常拦截");
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{rateDate:yyyy年MM月dd日}的价格");
if (IndexFixer.TryGetFixing(IndexFixerBase.GetFixingDate(startDate, position.interest_rule), position.FloatRateUnderlyingCode, out decimal fb))
return fb;
return position.FloatRate;
}
// 算头不算尾(calcLast=false)endDate 当天不计息,其 FR007 利率不参与计息,
// 缺价时直接沿用已有利率,不回退取其他日期利率,不告警。
var kept = preEod.id != 0 ? preEod.FloatRate : position.FloatRate;
SwapCalcTrace.Critical(
$"FIX GetFloatRate p{position.id} {rateDate:yyyy-MM-dd}缺价且不算尾→沿用已有利率={kept:P6}(来源={(preEod.id != 0 ? "preEod.FloatRate" : "position.FloatRate")})");
return kept;
}
SwapCalcTrace.Critical($"FIX GetFloatRate p{position.id} 互换事件(swap=true)→利率不参与,返回0");
return 0m;
}
@@ -1301,7 +1314,19 @@ namespace YLErp.Modules.SwapModule
if (string.IsNullOrEmpty(position.FloatRateUnderlyingCode)) return fallback;
var fixingDate = IndexFixerBase.GetFixingDate(date, position.interest_rule);
if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal fixing))
return fixing != 0m ? fixing : fallback;
{
if (fixing != 0m)
{
SwapCalcTrace.Critical(
$"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→定盘={fixing:P6}");
return fixing;
}
SwapCalcTrace.Critical(
$"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd})→定盘=0视为缺价,沿用fallback={fallback:P6}");
return fallback;
}
SwapCalcTrace.Critical(
$"FIX Resolve p{position.id} {date:yyyy-MM-dd}(取价日={fixingDate:yyyy-MM-dd} rule={position.interest_rule})→缺价,抛异常");
throw new Exception($"获取不到{position.FloatRateUnderlyingCode}在{fixingDate:yyyy年MM月dd日}的价格");
}
@@ -1317,26 +1342,37 @@ namespace YLErp.Modules.SwapModule
var rates = new List<(DateTime, decimal)>();
var calcDays = (endDate - startDate).Days;
decimal currentFloat = initialFloat;
SwapCalcTrace.Critical(
$"FIX Segments p{position.id} [{startDate:yyyy-MM-dd}→{endDate:yyyy-MM-dd}] period={interestPeriod} " +
$"fetchAfter={(fetchAfterDate?.ToString("yyyy-MM-dd") ?? "")} calcLast={calcLast} " +
$"排除起点={(relaxedFixingFromDate?.ToString("yyyy-MM-dd") ?? (calcLast ? "" : endDate.ToString("yyyy-MM-dd")))} seed={initialFloat:P6} spread={spread:P6}");
for (int i = 0; i <= calcDays; i += interestPeriod)
{
var resetDate = startDate.AddDays(i);
bool needFetch = (fetchAfterDate == null || resetDate > fetchAfterDate.Value);
// EQD-6968不算尾(calcLast=false)时,从真实平仓日(relaxedFixingFromDate,缺省取 endDate)起的重置
// 优先取新利率(满足 GLMS-JIATT-20260805 重置日=平仓日用新利率);缺失则沿用上一重置利率
// 不抛异常,以免上午 FR007 未发布误拦平仓。算尾(calcLast=true)时尾日定盘照常强制
bool relaxEndFixing = !calcLast && resetDate >= (relaxedFixingFromDate ?? endDate);
if (needFetch)
// 算头不算尾(calcLast=false)时,endDate 当天不计息,其重置日利率不参与计息。
// 从 relaxedFixingFromDate(缺省取 endDate)起的重置日:有价则取(满足重置日=平仓日用新利率)
// 缺价则跳过取价——currentFloat 保持不变(沿用上一重置日利率),不回退、不告警、不抛异常
// 算尾(calcLast=true)时所有重置日定盘照常强制取价。
bool isExcludedEnd = !calcLast && resetDate >= (relaxedFixingFromDate ?? endDate);
if (needFetch && !isExcludedEnd)
{
if (!relaxEndFixing)
currentFloat = ResolveFloatRate(position, resetDate, currentFloat);
}
else if (needFetch && isExcludedEnd)
{
// 有价则取新利率(重置日=平仓日时仍优先使用新利率);无价则跳过——该日利率不参与计息
var fixingDate = IndexFixerBase.GetFixingDate(resetDate, position.interest_rule);
if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal r))
{
currentFloat = ResolveFloatRate(position, resetDate, currentFloat);
currentFloat = r;
SwapCalcTrace.Critical(
$"FIX Segment p{position.id} {resetDate:yyyy-MM-dd} 排除日有价→取新定盘(取价日={fixingDate:yyyy-MM-dd})={r:P6}");
}
else
{
var fixingDate = IndexFixerBase.GetFixingDate(resetDate, position.interest_rule);
if (IndexFixer.TryGetFixing(fixingDate, position.FloatRateUnderlyingCode, out decimal r))
currentFloat = r;
// 否则沿用 currentFloat(上一重置日利率)
SwapCalcTrace.Critical(
$"FIX Segment p{position.id} {resetDate:yyyy-MM-dd} 排除日缺价→跳过取价,沿用={currentFloat:P6}(该日不计息)");
}
}
rates.Add((resetDate, spread + currentFloat));
@@ -1363,8 +1399,8 @@ namespace YLErp.Modules.SwapModule
int interestPeriod = position.interest_rest_days ?? 1;
// 分段取率:复利全程重放,每个重置日(含 startDate)取 FR007fetchAfterDate=null)。
// EQD-6968calcLast 透传,与下方 AccruePeriod 的边界同源,避免两处漂移;
// relaxedFixingFromDate 仅重放补计不算尾漏计利息时非 null(=真实平仓日),锚定尾日定盘放松点。
// calcLast 透传,与下方 AccruePeriod 的边界同源,避免两处漂移;
// relaxedFixingFromDate 仅重放补计不算尾漏计利息时非 null(=真实平仓日),锚定尾日跳过取价起点。
var (segmentRates, currentFloat) = BuildSegmentRates(
startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate,
fetchAfterDate: null, calcLast: calcLast, relaxedFixingFromDate: relaxedFixingFromDate);
@@ -1407,7 +1443,7 @@ namespace YLErp.Modules.SwapModule
var accrualBasis = preEodPosition.TdInterestPrincipal + posiPrincipal - orginPv;
// 分段取率:仅 ValueDate 之后的重置日才取 FR007fetchAfterDate=ValueDate)。
// EQD-6968calcLast 透传,与下方 AccruePeriod 的边界同源,避免两处漂移。
// calcLast 透传,与下方 AccruePeriod 的边界同源,避免两处漂移。
var (segmentRates, currentFloat) = BuildSegmentRates(
startDate, endDate, interestPeriod, position, flowEvent.InterestRate, floateRate,
fetchAfterDate: preEodPosition.ValueDate, calcLast: calcLast, relaxedFixingFromDate: relaxedFixingFromDate);