Files
zszq-trs/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs
hjhan 6e2cae7c1f 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
2026-08-19 11:07:10 +08:00

507 lines
26 KiB
C#
Raw Permalink 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;
using YLErp.DBModels.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 复利 consumedInterest 扣除 - 合成单元测试
/// ============================================================================
/// 验证 c6adb3bb 的修复:复利路径平仓时,扣除历史已通过互换结出的利息。
///
/// 核心场景:
/// 一笔复利交易,N天后做了互换结算(已结N天利息),之后再平仓。
/// 平仓默认值应 = 从头算的全程利息 - 已结利息(consumedInterest)。
/// 如果不扣(bug),平仓默认值 = 全程利息(偏大)。
/// 如果多扣(之前单利的错误),平仓默认值 = 0或负(偏小)。
///
/// 模仿 GetInterestsUnitTest_T0 的 StubSwapDealService 模式。
/// ============================================================================
[TestClass]
public class ConsumedInterestScenarioTest
{
#region 常量
private const decimal Principal = 1000m;
private const decimal FixedRate = 0.0025m; // 加点利率
private const double FloatRate = 0.001; // FR007
private const decimal TotalRate = FixedRate + (decimal)FloatRate; // 综合年化利率
private const int AnnualDays = 365;
private const int ResetPeriod = 3;
private static readonly DateTime StartDate = new(2026, 4, 27);
private static readonly DateTime ExerciseDate = new(2027, 4, 27);
#endregion
#region Stub:内存 SwapDealService + consumedInterest 注入
/// <summary>
/// 继承 SwapDealServiceoverride 两个虚方法:
/// - TryGetFloatRate:返回固定浮动利率(不连库)
/// - GetConsumedInterest:返回注入的历史已结利息(不连库)
/// </summary>
private sealed class StubSwapDealService : SwapDealService
{
private readonly double _floatRate;
private readonly decimal _consumedInterest;
private readonly Func<DateTime, double> _floatRateByDate;
public StubSwapDealService(OptUserInfo optUser, double floatRate, decimal consumedInterest)
: base(optUser)
{
_floatRate = floatRate;
_consumedInterest = consumedInterest;
_floatRateByDate = null;
}
/// <summary>按查询日期返回不同浮动利率(用于复现重置日取价 bug</summary>
public StubSwapDealService(OptUserInfo optUser, Func<DateTime, double> floatRateByDate, decimal consumedInterest = 0m)
: base(optUser)
{
_floatRate = 0;
_consumedInterest = consumedInterest;
_floatRateByDate = floatRateByDate;
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
rate = _floatRateByDate != null ? _floatRateByDate(valueDate) : _floatRate;
return true;
}
public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate)
{
return _consumedInterest; // 返回注入值
}
}
#endregion
#region 数据构建
private static trade CreateTrade()
{
return new trade
{
id = 1, TradeNumber = "UT-CONSUMED-001", ClientId = 999998,
TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate,
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "10", // 算头不算尾
SettlementRules = 0
})
}
};
}
private static swap_position CreateCompoundPosition()
{
return new swap_position
{
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价,
InterestRateDefault = FixedRate, InterestPrincipalFix = Principal,
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利,
IsAnnualized = true, interest_rest_days = ResetPeriod, interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 }
})
};
}
/// <summary>调用 GetInterests 获取复利利息(统一调用入口,settment:false走盘中平仓路径)</summary>
private static swap_flow_event CalcCompoundUnwind(StubSwapDealService service, DateTime unwindDate, decimal closePercent = 1m)
{
var td = CreateTrade();
var position = CreateCompoundPosition();
var interests = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, closePercent,
(int)SwapEventTypeEnum.平仓, false, Principal,
add: false, settment: false, newCalcLast: false);
Assert.AreEqual(1, interests.Count);
return interests[0];
}
private static StubSwapDealService CreateService(decimal consumedInterest)
{
return new StubSwapDealService(
new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest),
FloatRate, consumedInterest);
}
private static void AssertDecimal(decimal expected, decimal actual, string message = "")
{
var tolerance = 1m / (decimal)Math.Pow(10, ConsGlobal.PriceRound - 2);
Assert.IsTrue(Math.Abs(expected - actual) <= tolerance,
$"{message} Expected: {expected}, Actual: {actual}, Diff: {expected - actual}");
}
#endregion
// ================================================================
// 场景1:基线——无历史互换(consumedInterest=0),拿到全程复利利息
// ================================================================
/// <summary>
/// [CI_001] 无历史互换结清,复利平仓利息基线
/// ---------------------------------------------------------------
/// consumedInterest=0,平仓利息=从头算的全程复利利息。
/// 此值作为后续场景的参照基线(避免独立复利计算的精度匹配问题)。
/// ---------------------------------------------------------------
/// </summary>
[TestMethod]
public void CI_001_无历史互换平仓利息基线()
{
var unwindDate = StartDate.AddDays(10); // 4/27+10=5/7,算头不算尾约9天
var service = CreateService(consumedInterest: 0m);
var result = CalcCompoundUnwind(service, unwindDate);
Assert.IsTrue(result.InterestAmount > 0, "无互换时复利利息应>0");
Console.WriteLine($"基线(consumedInterest=0): InterestAmount={result.InterestAmount:F6}");
}
// ================================================================
// 场景2consumedInterest>0 → 平仓利息=基线-consumedInterest
// ================================================================
/// <summary>
/// [CI_002] 注入consumedInterest后,平仓利息应=基线-consumedInterest
/// ---------------------------------------------------------------
/// 用相同参数但注入不同的consumedInterest,验证:
/// 利息(有consumed) = 利息(无consumed) - consumedInterest
/// 这是验证cs:793 `interest -= consumedInterest` 的直接方式。
/// ---------------------------------------------------------------
/// </summary>
[TestMethod]
public void CI_002_consumedInterest正确扣除()
{
var unwindDate = StartDate.AddDays(10);
// 基线:consumedInterest=0
var baselineResult = CalcCompoundUnwind(CreateService(0m), unwindDate);
decimal baseline = baselineResult.InterestAmount;
// 注入consumedInterest=基线的50%
decimal consumed = baseline * 0.5m;
var consumedResult = CalcCompoundUnwind(CreateService(consumed), unwindDate);
// 期望 = 基线 - consumed
decimal expected = baseline - consumed;
AssertDecimal(expected, consumedResult.InterestAmount,
$"平仓利息应=基线({baseline:F6})-consumed({consumed:F6})={expected:F6}");
Console.WriteLine($"基线={baseline:F6}, consumed={consumed:F6}");
Console.WriteLine($"平仓利息={consumedResult.InterestAmount:F6}, 期望={expected:F6} ✅");
}
// ================================================================
// 场景3:守恒——consumed + 平仓利息 = 基线
// ================================================================
/// <summary>
/// [CI_003] 守恒:consumedInterest + 平仓利息(扣后) = 基线(无consumed)
/// ---------------------------------------------------------------
/// 注入任意consumedInterest,验证 consumed + 利息 = 基线。
/// 如果扣多了(守恒不成立→合计<基线)或没扣(合计>基线),测试失败。
/// ---------------------------------------------------------------
/// </summary>
[TestMethod]
public void CI_003_守恒consumed加平仓等于基线()
{
var unwindDate = StartDate.AddDays(10);
decimal baseline = CalcCompoundUnwind(CreateService(0m), unwindDate).InterestAmount;
// 注入不同的consumedInterest验证守恒
decimal[] testConsumed = { baseline * 0.3m, baseline * 0.5m, baseline * 0.8m };
foreach (var consumed in testConsumed)
{
var result = CalcCompoundUnwind(CreateService(consumed), unwindDate);
decimal actual = consumed + result.InterestAmount;
AssertDecimal(baseline, actual,
$"守恒: consumed({consumed:F6}) + 利息({result.InterestAmount:F6}) = {actual:F6} 应=基线({baseline:F6})");
Console.WriteLine($"consumed={consumed:F6} + 利息={result.InterestAmount:F6} = {actual:F6} = 基线{baseline:F6} ✅");
}
}
// ================================================================
// 场景4consumedInterest=全部基线 → 平仓利息≈0,不为负
// ================================================================
/// <summary>
/// [CI_004] 全部利息已结清(consumedInterest=基线),再平仓利息应≈0
/// ---------------------------------------------------------------
/// 验证不会扣过头变成负数(之前单利双重扣减的错误)。
/// 复利从头算全程 - 全程consumed = 0,应精确归零或微小正值。
/// ---------------------------------------------------------------
/// </summary>
[TestMethod]
public void CI_004_全部已结再平仓利息不为负()
{
var unwindDate = StartDate.AddDays(10);
decimal baseline = CalcCompoundUnwind(CreateService(0m), unwindDate).InterestAmount;
// consumedInterest=全部基线
var result = CalcCompoundUnwind(CreateService(baseline), unwindDate);
Console.WriteLine($"基线={baseline:F6}, consumed={baseline:F6}, 平仓利息={result.InterestAmount:F6}");
Assert.IsTrue(result.InterestAmount >= -0.01m,
$"全部已结再平仓利息应≈0(实际={result.InterestAmount:F6}),不应为负");
Console.WriteLine($"全部已结平仓≈0{result.InterestAmount:F6})✅");
}
[TestMethod]
public void CI_005_partialClose_scalesConsumedInterest()
{
var unwindDate = StartDate.AddDays(10);
const decimal closePercent = 0.4m;
const decimal consumed = 100m;
var baseline = CalcCompoundUnwind(CreateService(0m), unwindDate, closePercent).InterestAmount;
var result = CalcCompoundUnwind(CreateService(consumed), unwindDate, closePercent).InterestAmount;
AssertDecimal(baseline - consumed * closePercent, result,
$"partial close should deduct consumed interest by closePercent ({closePercent})");
}
// ================================================================
// 场景7:平仓日=重置日 + calcLast=false 的事件利率口径(EQD-6968 自洽化后)
// ================================================================
/// <summary>
/// [CI_007] 平仓日=重置日 + calcLast=false:排除日不取价,事件利率=末段已消费利率(确定性)
/// ----------------------------------------------------------------
/// 历史(GLMS-JIATT-20260805):原缺陷是重置日取价被 calcLast 跳过 → flowEvent.FloatRate 停留旧值
/// → 落库后传染 EOD。当时的修复=排除日"有价则取新定盘",事件利率因而取决于平仓时刻
/// (上午=旧/下午=新),与金额实际使用的利率脱钩。
///
/// EQD-6968 自洽化后的新契约:
/// ① 已平部分:排除日一概不取价(有价也不取),事件 FloatRate=末段已消费利率(rateOld),
/// 与金额同源、与平仓时刻无关;
/// ② 剩余持仓的新周期利率:由 EOD 快照"重置日再定盘"显式获取
/// InterestEodTailSnapshotTest.CloseOnly_平仓日为重置日_剩余持仓快照再定盘)。
///
/// 构造(避开周末,period=7)PosiStartDate=6/1(周一), 平仓日=6/8(周一,重置日,7%7=0)
/// FR007 分界:取价日>=6/8 返回 rateNew,否则 rateOld。
/// ----------------------------------------------------------------
/// </summary>
[TestMethod]
public void CI_007_平仓日等于重置日_calcLast_false_事件利率为末段已消费利率()
{
const double rateOld = 0.001;
const double rateNew = 0.002;
// 用 6 月日期避开五一/周末:PosiStartDate=6/1(周一), period=7, 平仓日=6/8(周一,重置日)
DateTime posiStart = new DateTime(2026, 6, 1);
DateTime unwindDate = new DateTime(2026, 6, 8); // (6/8-6/1)=7, 7%7=0 重置日
DateTime newRateFrom = new DateTime(2026, 6, 8); // 6/8(查询日,周一工作日)起为新利率
StubSwapDealService ServiceByDate() => new StubSwapDealService(
new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest),
d => d >= newRateFrom ? rateNew : rateOld);
var td = new trade
{
id = 1, TradeNumber = "UT-CI007", ClientId = 999998,
TradeType = "收益互换", TradeDate = posiStart, StartDate = posiStart,
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
StructureType = "单标的", QuoteCurrency = "CNY", SettlementCurrency = "CNY",
trade_extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays, InterestCalcMode = "10", SettlementRules = 0
})
}
};
var position = new swap_position
{
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)InterestModeEnum.标的期初全价,
InterestRateDefault = FixedRate, InterestPrincipalFix = Principal,
PosiStartDate = posiStart, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.复利,
IsAnnualized = true, interest_rest_days = 7, interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 }
})
};
var interests = ServiceByDate().GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position>(), new List<swap_position> { position },
Principal, Principal, 1m,
(int)SwapEventTypeEnum.平仓, false, Principal,
add: false, settment: false, newCalcLast: false);
Assert.AreEqual(1, interests.Count);
var result = interests[0];
Console.WriteLine($"6/8(重置日,周一)平仓:FloatRate={result.FloatRate} Amount={result.InterestAmount:F6}");
Console.WriteLine($" 新口径期望 FloatRate={rateOld}(排除日不取价,事件利率=末段已消费利率)");
// 核心断言(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,
"calcLast=false 的重置日仍应将前 7 天复利并入本金");
AssertDecimal(interestBeforeResetDate, result.InterestAmount,
"calcLast=false 不应计入重置日当天利息");
}
[TestMethod]
public void CI_008_ResetDayPartialCloseCarriesRemainingInterestIntoPrincipal()
{
const decimal previousPrincipal = 50061728.39m;
const decimal remainingPrincipal = 30037037.04m;
const decimal previousInterest = 7425.050203320057m;
const decimal fixedRate = 0.001234m;
const double oldFloatRate = 0.0123;
const double newFloatRate = 0.0213;
var startDate = new DateTime(2026, 7, 28);
var resetDate = new DateTime(2026, 8, 4);
var service = new StubSwapDealService(
new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest),
d => d >= resetDate ? newFloatRate : oldFloatRate);
var td = CreateTrade();
td.StartDate = startDate;
td.TradeDate = startDate;
var position = new swap_position
{
id = 1001, SwapTradeId = td.id,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestMode = (int)InterestModeEnum.标的期初全价,
InterestType = (int)InterestTypeEnum.复利,
InterestRateDefault = fixedRate,
PosiStartDate = startDate, PosiMatuirityDate = ExerciseDate,
IsAnnualized = true, interest_rest_days = 7, interest_rule = 0,
FloatRateUnderlyingCode = "FR007",
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>
{
new IntervalModel { Date = ExerciseDate, Rate = fixedRate, Settlement = 0 }
})
};
var preEod = new eod_swap_position
{
id = 1, PositionId = position.id, ValueDate = resetDate.AddDays(-1),
TdInterestPrincipal = previousPrincipal,
InterestIncomeSum = previousInterest,
InterestProfitSum = previousInterest,
FloatRate = (decimal)oldFloatRate
};
var result = service.GetInterests(td, td.trade_extend, resetDate, resetDate,
new List<eod_swap_position> { preEod }, new List<swap_position> { position },
remainingPrincipal, remainingPrincipal, 1m,
(int)SwapEventTypeEnum.平仓, true, remainingPrincipal,
add: false, settment: false, newCalcLast: false).Single();
var remainingInterest = previousInterest * remainingPrincipal / previousPrincipal;
var expectedPrincipal = remainingPrincipal + remainingInterest;
AssertDecimal(expectedPrincipal, result.InterestPrincipal);
// EQD-6968 自洽化:排除日(平仓日=重置日)不取价,事件利率=末段已消费利率(旧)——与金额同源、
// 与平仓时刻无关。剩余持仓的新周期利率由 EOD 快照"重置日再定盘"显式获取
// InterestEodTailSnapshotTest.CloseOnly_平仓日为重置日_剩余持仓快照再定盘)。
AssertDecimal((decimal)oldFloatRate, result.FloatRate.Value,
"排除日不取价:事件 FloatRate 应=末段已消费旧利率");
}
[TestMethod]
public void CI_009_NonResetUnwindWithCalcLastFalseUsesPreviousEodPendingInterest()
{
const decimal pendingInterest = 10019.043756537721m;
const decimal remainingPrincipal = 30041492.070122881942m;
var startDate = new DateTime(2026, 7, 28);
var unwindDate = new DateTime(2026, 8, 7);
var td = CreateTrade();
td.StartDate = startDate;
td.TradeDate = startDate;
var position = new swap_position
{
id = 1001, SwapTradeId = td.id,
InterestDirection = (int)SwapDirectionEnum.收取,
InterestMode = (int)InterestModeEnum.标的期初全价,
InterestType = (int)InterestTypeEnum.复利,
InterestRateDefault = 0.001234m,
PosiStartDate = startDate, PosiMatuirityDate = ExerciseDate,
IsAnnualized = true, interest_rest_days = 7,
FloatRateUnderlyingCode = "FR007"
};
var preEod = new eod_swap_position
{
id = 1, PositionId = position.id, ValueDate = unwindDate.AddDays(-1),
TdInterestPrincipal = remainingPrincipal,
InterestIncomeSum = pendingInterest,
InterestProfitSum = pendingInterest,
FloatRate = 0.0213m
};
var service = new StubSwapDealService(
new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest), 0.0213, 0m);
var result = service.GetInterests(td, td.trade_extend, unwindDate, unwindDate,
new List<eod_swap_position> { preEod }, new List<swap_position> { position },
remainingPrincipal, remainingPrincipal, 1m,
(int)SwapEventTypeEnum.平仓, false, remainingPrincipal,
add: false, settment: false, newCalcLast: false).Single();
AssertDecimal(pendingInterest, result.InterestAmount,
"calcLast=false must not accrue unwind-date interest after the previous EOD");
}
[TestMethod]
public void CI_010_EodResetWithoutCloseCarriesFullPendingInterest()
{
const decimal principal = 303139117.80m;
const decimal previousBase = 303230391.742592383565m;
const decimal pendingInterest = 184331.611361300669m;
var startDate = new DateTime(2026, 4, 21);
var resetDate = new DateTime(2026, 4, 28);
var position = new swap_position
{
PosiStartDate = startDate,
InterestType = (int)InterestTypeEnum.复利,
InterestRateDefault = 0.0025m,
InterestSwapInterval = JsonConvert.SerializeObject(new List<IntervalModel>()),
IsAnnualized = true,
interest_rest_days = 7,
FloatRateUnderlyingCode = "FR007"
};
var preEod = new eod_swap_position
{
id = 1,
ValueDate = resetDate.AddDays(-1),
TdInterestPrincipal = previousBase,
InterestIncomeSum = pendingInterest,
InterestProfitSum = pendingInterest,
FloatRate = 0.013502m
};
var flowEvent = new swap_flow_event { InterestRate = 0.0025m };
var service = new StubSwapDealService(
new OptUserInfo(0, nameof(ConsumedInterestScenarioTest), OptUserFrom.UnitTest),
d => 0.0139);
decimal interestAmount = 0m;
decimal tdInterestAmount = 0m;
service.CalcDailyCompoundInterestByEod(preEod, resetDate, startDate, position,
principal, principal, flowEvent, AnnualDays, 0.013502m, 1m,
ref interestAmount, ref tdInterestAmount);
AssertDecimal(principal + pendingInterest, flowEvent.InterestPrincipal,
"无平仓重置日必须完整并入上一期累计待实现利息");
}
}
}