test(swap): EQD-6968 自洽化契约钉死——事件利率确定性+快照再定盘载体+CI_007/008 新口径

- Determinism_NoTail_EventFloatRateIndependentOfPublishTime:尾日价缺(上午) vs
  有(下午)两世界,金额与事件 FloatRate 完全一致(=末段已消费利率0.01425)
- CloseOnly_平仓日为重置日_剩余持仓快照再定盘(+非重置日反例):经 DealInterests
  真路由断言快照 FloatRate=当日新定盘(RefixCalls 计数)——载体职责显式化验收
- CalcFirst 用例去掉 closeRate 免疫 hack:priorValueDate 根治后首重置日恒取价
- CI_007 更名+断言翻转:平仓日=重置日+不算尾 → 事件利率=末段已消费利率(旧);
  原断言(取新定盘)正是被替换的旧口径契约,根因注释留痕 GLMS-JIATT-20260805
- CI_008:本金结转断言保留,利率断言改为末段旧利率

验证:ConsumedInterestScenario 9/9、InterestEodTailSnapshot+Fr007 36/36、
全量 976 例 145 败与基线 diff=0
This commit is contained in:
hjhan
2026-08-19 11:07:10 +08:00
parent fafae5cac0
commit 6e2cae7c1f
3 changed files with 142 additions and 43 deletions
@@ -273,45 +273,28 @@ namespace YLErp.Modules.SwapModule
}
// ================================================================
// 场景7复现"平仓日=重置日 + calcLast=false → 重置日跳过 FR007 取价"
// 场景7:平仓日=重置日 + calcLast=false 的事件利率口径(EQD-6968 自洽化后)
// ================================================================
/// <summary>
/// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过
/// [CI_007] 平仓日=重置日 + calcLast=false:排除日不取价,事件利率=末段已消费利率(确定性)
/// ----------------------------------------------------------------
/// 背景(GLMS-JIATT-20260805 根因)InterestCalcMode='10'(算头不算尾,calcLast=false)
/// CalcDailyCompoundInterest 循环里 `if(!calcLast && accrueDate==endDate) continue` 会跳过平仓日当天。
/// 若平仓日恰好是重置日(i%period==0),这个跳过会让"重置日取新FR007"的代码块永远不执行,
/// 沿用上一个重置周期的旧利率。
/// 历史(GLMS-JIATT-20260805):原缺陷是重置日取价被 calcLast 跳过 → flowEvent.FloatRate 停留旧值
/// → 落库后传染 EOD。当时的修复=排除日"有价则取新定盘",事件利率因而取决于平仓时刻
/// (上午=旧/下午=新),与金额实际使用的利率脱钩。
///
/// 构造:PosiStartDate=4/27, ResetPeriod=3, InterestCalcMode='10'(calcLast=false)
/// - FR007 按日期分段:5/3之前返回 rateOld=0.0015/3及之后返回 rateNew=0.002
/// - 对照A:平仓日=5/5(非重置日,9? 不: (5/5-4/27)=8, 8%3=2 非重置) → 不该取新值
/// - 对照B:平仓日=5/6(重置日,(5/6-4/27)=9, 9%3=0) → 应取新值 rateNew
/// EQD-6968 自洽化后的新契约:
/// ① 已平部分:排除日一概不取价(有价也不取),事件 FloatRate=末段已消费利率(rateOld),
/// 与金额同源、与平仓时刻无关;
/// ② 剩余持仓的新周期利率:由 EOD 快照"重置日再定盘"显式获取
/// InterestEodTailSnapshotTest.CloseOnly_平仓日为重置日_剩余持仓快照再定盘)。
///
/// 修复前:5/6 重置日被 calcLast 跳过 → 取到旧 rateOld → 与 5/5 相同
/// 修复后:5/6 重置日正常取价 → 取到 rateNew → 与 5/5 不同
/// ----------------------------------------------------------------
/// </summary>
/// <summary>
/// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过
/// ----------------------------------------------------------------
/// 根因(GLMS-JIATT-20260805)InterestCalcMode='10'(calcLast=false)
/// CalcDailyCompoundInterest 循环 `if(!calcLast && accrueDate==endDate) continue` 跳过平仓日。
/// 若平仓日=重置日,取价代码块被跳过 → flowEvent.FloatRate 停留旧值 → 落库后传染 EOD。
///
/// 构造(避开周末,period=7)
/// PosiStartDate=4/27(周一), period=7, interest_rule=0, InterestCalcMode='10'
/// 重置日:i=0→4/27(周一), i=7→5/4(周一,工作日)
/// 平仓日=5/4(=重置日=endDate)
/// FR007 分界:rateDate>=5/4 返回 rateNew,否则 rateOld
///
/// 修复前:i=7(5/4)被 calcLast 跳过 → FloatRate=rateOld(旧值)
/// 修复后:i=7(5/4)正常取价 → FloatRate=rateNew(新值)
/// 构造(避开周末,period=7)PosiStartDate=6/1(周一), 平仓日=6/8(周一,重置日,7%7=0)
/// FR007 分界:取价日>=6/8 返回 rateNew,否则 rateOld。
/// ----------------------------------------------------------------
/// </summary>
[TestMethod]
public void CI_007_平仓日等于重置日_calcLast_false_仍应取新FR007()
public void CI_007_平仓日等于重置日_calcLast_false_事件利率为末段已消费利率()
{
const double rateOld = 0.001;
const double rateNew = 0.002;
@@ -363,12 +346,14 @@ namespace YLErp.Modules.SwapModule
Assert.AreEqual(1, interests.Count);
var result = interests[0];
Console.WriteLine($"6/8(重置日,周一)平仓:FloatRate={result.FloatRate} Amount={result.InterestAmount:F6}");
Console.WriteLine($" 期望 FloatRate={rateNew}(6/8 重置日查询日=6/8工作日,应取新利率)");
Console.WriteLine($" 新口径期望 FloatRate={rateOld}(排除日不取价,事件利率=末段已消费利率)");
// 核心断言6/8 是重置日,flowEvent.FloatRate 应反映新利率 rateNew
Assert.IsTrue(Math.Abs((result.FloatRate ?? 0) - (decimal)rateNew) < 0.0001m,
$"平仓日=重置日时 FloatRate 应={rateNew}(取到新利率)。" +
$"实际={result.FloatRate},若={rateOld} 说明 calcLast=false 跳过了重置日取价(GLMS-JIATT-20260805 根因)");
// 核心断言(EQD-6968 自洽化契约):排除日(不计息)一概不取价——即使 6/8 新定盘已发布,
// 事件 FloatRate 也必须是末段已消费利率 rateOld,与金额同源、与平仓时刻无关。
// 剩余持仓的新周期利率由 EOD 快照"重置日再定盘"显式获取(见 InterestEodTailSnapshotTest)。
Assert.IsTrue(Math.Abs((result.FloatRate ?? 0) - (decimal)rateOld) < 0.0001m,
$"排除日不取价:FloatRate 应=末段已消费利率 {rateOld}。实际={result.FloatRate}" +
$"若={rateNew} 说明排除日仍在取价(旧口径:记录利率取决于平仓时刻)");
var interestBeforeResetDate = Principal * (FixedRate + (decimal)rateOld) * 7m / AnnualDays;
AssertDecimal(Principal + interestBeforeResetDate, result.InterestPrincipal,
@@ -426,10 +411,12 @@ namespace YLErp.Modules.SwapModule
var remainingInterest = previousInterest * remainingPrincipal / previousPrincipal;
var expectedPrincipal = remainingPrincipal + remainingInterest;
var expectedDailyInterest = expectedPrincipal * (fixedRate + (decimal)newFloatRate) / AnnualDays;
AssertDecimal(expectedPrincipal, result.InterestPrincipal);
AssertDecimal(expectedDailyInterest,
result.InterestPrincipal * (result.InterestRate + result.FloatRate.Value) / AnnualDays);
// EQD-6968 自洽化:排除日(平仓日=重置日)不取价,事件利率=末段已消费利率(旧)——与金额同源、
// 与平仓时刻无关。剩余持仓的新周期利率由 EOD 快照"重置日再定盘"显式获取
// InterestEodTailSnapshotTest.CloseOnly_平仓日为重置日_剩余持仓快照再定盘)。
AssertDecimal((decimal)oldFloatRate, result.FloatRate.Value,
"排除日不取价:事件 FloatRate 应=末段已消费旧利率");
}
[TestMethod]
@@ -439,11 +439,10 @@ namespace YLErp.Modules.SwapModule
[TestMethod]
public void CalcFirst_Simple_NoHeadDropsExactlyStartDayInterest()
{
// closeRate=0.0142 与 7/6 定盘相同:使"00"首段种子利率与"10"首段取价利率一致,
// 断言只对"少计开始日一天"敏感,免疫无日终快照时 fetchAfter=interestStart-1
// 跳过 7/6 取价、首段用种子利率的既有取价细节。
var w10 = Run(InterestTypeEnum., includeCloseDate: true, calcMode: "10", closeRate: 0.0142);
var w00 = Run(InterestTypeEnum., includeCloseDate: true, calcMode: "00", closeRate: 0.0142);
// 无日终快照时 priorValueDate=开始日-1(首重置日 7/6 恒在取价窗内),两世界首段利率同为 7/6 定盘;
// "00" 比"10"恰好少计开始日一天——精确断言钉 CalcFirst 边界与首重置日取价窗。
var w10 = Run(InterestTypeEnum., includeCloseDate: true, calcMode: "10");
var w00 = Run(InterestTypeEnum., includeCloseDate: true, calcMode: "00");
Assert.IsFalse(w10.Threw, "10 不应抛:" + w10.Ex?.Message);
Assert.IsFalse(w00.Threw, "00 不应抛:" + w00.Ex?.Message);
// 开始日 7/6 属首段:all-in = spread(-0.0155) + 定盘(0.0142) = -0.0013
@@ -453,6 +452,27 @@ namespace YLErp.Modules.SwapModule
"不算头(00)应恰好少计开始日一天利息(CalcFirst 回归锚)");
}
// ── 事件利率确定性(EQD-6968 自洽化):排除日不取价,事件利率=末段已消费利率 ──
// 同一交易同一天,尾日价缺(上午平仓) vs 有(下午平仓):金额与落库 FloatRate 必须完全一致,
// 杜绝"记录利率取决于点击时刻"。
[TestMethod]
public void Determinism_NoTail_EventFloatRateIndependentOfPublishTime()
{
var wMorning = Run(InterestTypeEnum., includeCloseDate: false, calcMode: "10",
preEod: BuildPreEod(new DateTime(2026, 7, 13)));
var wAfternoon = Run(InterestTypeEnum., includeCloseDate: true, calcMode: "10", closeRate: 0.0199,
preEod: BuildPreEod(new DateTime(2026, 7, 13)));
Assert.IsFalse(wMorning.Threw, "上午世界不应抛:" + wMorning.Ex?.Message);
Assert.IsFalse(wAfternoon.Threw, "下午世界不应抛:" + wAfternoon.Ex?.Message);
Assert.AreEqual(wMorning.Fe.InterestAmount, wAfternoon.Fe.InterestAmount,
"金额不应因尾日价发布与否而变化(尾日利率零消费)");
Assert.AreEqual(0.01425m, wMorning.Fe.FloatRate,
"事件利率=末段已消费利率(7/13定盘 0.01425),非尾日价");
Assert.AreEqual(wMorning.Fe.FloatRate, wAfternoon.Fe.FloatRate,
"事件利率必须与平仓时刻(尾日价发布前后)无关");
}
// ── EOD 收盘归档路径(settment=true,此前全套件仅覆盖盘中 settment:false)──
// EOD 不取尾日价的依赖链:InitInterestDate 到期日回拨(endDate=D-1) + CalcEodInterest 的
// calcToday=false(valueDate==到期日且不算尾) 整体跳过 ByEod 重算——ByEod 的取价
@@ -60,6 +60,16 @@ namespace YLErp.Modules.SwapModule
return CalcResult;
}
/// <summary>持仓延续腿重置日再定盘接缝:计数并返回受控新定盘(不连库)</summary>
public decimal RefixResult { get; set; }
public int RefixCalls { get; private set; }
protected override decimal ResolveOngoingResetFixing(swap_position position, DateTime valueDate)
{
RefixCalls++;
return RefixResult;
}
public List<swap_flow_event> ExecuteDealInterests(
List<swap_position> interestList, List<eod_swap_position> eodPositions,
DateTime settleDate, trade td, List<swap_flow_event> flowEvents,
@@ -223,5 +233,87 @@ namespace YLErp.Modules.SwapModule
Assert.AreEqual(5.295342465753m, p.SwapPositionValue);
Assert.AreEqual(1.0m, p.TdCurrency);
}
#region EQD-6968
private const decimal OldFloat = 0.01425m;
private const decimal NewFloat = 0.0143m;
/// <summary>4/27+147 天周期的重置日平仓</summary>
private static readonly DateTime ResetSettle = StartDate.AddDays(14);
/// <summary>4/27+10:非重置日平仓(10%7≠0</summary>
private static readonly DateTime NonResetSettle = StartDate.AddDays(10);
private static swap_position CreateFloatLegPosition() => new()
{
id = 1001, SwapTradeId = 1, InterestDirection = (int)SwapDirectionEnum.,
InterestMode = (int)InterestModeEnum., InterestRateDefault = Rate,
InterestPrincipalFix = Principal, PosiStartDate = StartDate,
PosiMatuirityDate = new DateTime(2027, 4, 27), IsInitial = true,
InterestType = (int)InterestTypeEnum., IsAnnualized = true,
interest_rest_days = 7, interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{ new IntervalModel { Date = StartDate, Rate = Rate, Settlement = 1 } })
};
private static eod_swap_position CreatePreEodBefore(DateTime settle, decimal accumulated) => new()
{
id = 100, PositionId = 1001, ValueDate = settle.AddDays(-1),
InterestDirection = (int)SwapDirectionEnum., InterestMode = (int)InterestModeEnum.,
InterestIncomeSum = accumulated, InterestProfitSum = accumulated,
InterestRateDefault = Rate, TdInterestPrincipal = Principal,
InterestType = (int)InterestTypeEnum., IsAnnualized = true, interest_rest_days = 7
};
private static swap_flow_event CalcResultWithFloat(decimal floatRate)
{
var e = CreateCalcResult();
e.FloatRate = floatRate;
return e;
}
/// <summary>
/// 平仓日恰为重置日且剩余持仓>0:快照 FloatRate 必须显式再定盘为当日新定盘——
/// 它是后续非重置日(ByEod 沿用 preEod.FloatRate)与当日应计(intersetAcmount)的利率载体。
/// 排除日"纯跳过"后事件利率=末段已消费利率(OldFloat),载体职责与本步骤显式分离。
/// </summary>
[TestMethod]
public void CloseOnly_平仓日为重置日_剩余持仓快照再定盘()
{
var service = new TailStubService
{
CalcResult = new List<swap_flow_event> { CalcResultWithFloat(OldFloat) },
RefixResult = NewFloat,
};
service.ExecuteDealInterests(
new List<swap_position> { CreateFloatLegPosition() },
new List<eod_swap_position> { CreatePreEodBefore(ResetSettle, Accrued10d) },
ResetSettle, CreateTrade(), new List<swap_flow_event> { CreateCloseEvent() },
Remaining, ClosedNotional, 100m, Remaining);
Assert.AreEqual(1, service.RefixCalls, "不算尾+平仓日=重置日+剩余>0:应恰好显式再定盘一次");
Assert.AreEqual(NewFloat, service.PersistedPositions.Single().FloatRate,
"剩余持仓快照利率=当日新定盘(非事件末段旧利率)");
}
[TestMethod]
public void CloseOnly_平仓日非重置日_不再定盘_快照沿用事件利率()
{
var service = new TailStubService
{
CalcResult = new List<swap_flow_event> { CalcResultWithFloat(OldFloat) },
RefixResult = NewFloat,
};
service.ExecuteDealInterests(
new List<swap_position> { CreateFloatLegPosition() },
new List<eod_swap_position> { CreatePreEodBefore(NonResetSettle, Accrued10d) },
NonResetSettle, CreateTrade(), new List<swap_flow_event> { CreateCloseEvent() },
Remaining, ClosedNotional, 100m, Remaining);
Assert.AreEqual(0, service.RefixCalls, "非重置日平仓:无需再定盘");
Assert.AreEqual(OldFloat, service.PersistedPositions.Single().FloatRate,
"快照沿用事件末段已消费利率(周期未切换)");
}
#endregion
}
}