Files
zszq-trs/UnitTestProject/Modules/SwapModule/ConsumedInterestScenarioTest.cs
T
hjhan 66a97e03ef fix(swap): 复利重置日=平仓日时 calcLast 不再跳过 FR007 取价
根因(GLMS-JIATT-20260805):InterestCalcMode='10'(算头不算尾,calcLast=false)时,
CalcDailyCompoundInterest 循环的 'if(!calcLast && accrueDate==endDate) continue'
会跳过平仓日。若平仓日恰好是重置日(i%period==0),取价代码块被一并跳过,
导致 flowEvent.FloatRate 落库为旧周期利率,传染后续 EOD 复利计算。

修复:把重置日的 FR007 取价提前到 calcFirst/calcLast 跳过判断之前——
calcLast 只应跳过'计息',不应跳过'重置日利率取价'。同时循环外用最终
floatRate 兜底赋值 flowEvent.FloatRate,确保落库值反映最后重置日的利率。

验证:
- CI_007 合成测试(平仓日=重置日):修复前 FloatRate=旧值(FAIL),修复后=新值(PASS)
- 连库验证(GLMS-JIATT-20260805 8/4平仓):FloatRate 0.0123→0.0213(8/3新值)
- 利息金额不变(calcLast 不计当天利息,Amount 不受影响,只修正 FloatRate 字段)
- 全套 swap 测试无新增回归(334通过,2失败均为pre-existing单利/EOD路径)

附:CI_007 复现测试 + GLMS20260805 FR007/EOD 诊断工具 + 文档 longRatio 状态更新
2026-08-07 20:43:16 +08:00

375 lines
20 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;
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, Principal, Principal, closePercent,
(int)SwapEventTypeEnum.平仓, false, false, Principal, 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 → 重置日跳过 FR007 取价"
// ================================================================
/// <summary>
/// [CI_007] 平仓日恰好是重置日时,calcLast=false 不应导致该重置日的 FR007 取价被跳过
/// ----------------------------------------------------------------
/// 背景(GLMS-JIATT-20260805 根因)InterestCalcMode='10'(算头不算尾,calcLast=false)
/// CalcDailyCompoundInterest 循环里 `if(!calcLast && accrueDate==endDate) continue` 会跳过平仓日当天。
/// 若平仓日恰好是重置日(i%period==0),这个跳过会让"重置日取新FR007"的代码块永远不执行,
/// 沿用上一个重置周期的旧利率。
///
/// 构造: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
///
/// 修复前: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(新值)
/// ----------------------------------------------------------------
/// </summary>
[TestMethod]
public void CI_007_平仓日等于重置日_calcLast_false_仍应取新FR007()
{
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, Principal, Principal, 1m,
(int)SwapEventTypeEnum.平仓, false, false, Principal, 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={rateNew}6/8 重置日查询日=6/8工作日,应取新利率)");
// 核心断言: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 根因)");
}
}
}