Files
zszq-trs/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs
T
hjhan 4a3fee9292 refactor(swap)+test: 删 GetInterests/CalcSwapInterests 死参数 needPrice/grossPrice;补工厂→接缝映射钉子
死参数收口(另一半):
- SwapDealService.GetInterests 删 needPrice/grossPrice(体内零消费,2026-08 验证);
  InitSwapDealInterest.needPrice 同为死参数一并删
- SwapEodPositionService.CalcSwapInterests 签名+转发同步;两个 EOD 生产调用点
  (SaveAutoEodInterestPosition/SaveEodInterestPositionCopy) 重排实参;
  CalcEodPostCloseSettleInterests/GetIntradayUnwindInterests 委托同步
- 14 个测试文件 ~44 处直调点机械更新(8 处 override 签名 + 36 处调用实参)
- 注意:EOD 编排链(DealInterests→Save*家族)的 grossPrice(期初不含费价)有真实用途,保留未动

新增钉子:CalcEodPostCloseSettleInterests 工厂→接缝参数映射测试——
CalcSwapInterestsCapture 捕获 stub 断言 EodPostCloseSettle 的完整转发契约
(posi=平仓后剩余/closePosi=平掉额/恒1/settment:false/orginPv 等 11 项)。
该段位置转发含三个相邻同型 decimal,编译器不查错位,此测试兜底。

验证:定向 241 测试通过(含 T0/T1 Excel 验证期望值、EntrySemantics 精确值钉子——
任何 decimal 错位即红);全量 903=145失败/746通过/12跳过,与基线逐位一致。
2026-08-14 16:57:56 +08:00

308 lines
17 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.Enums;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// GetInterests 双显式入口语义字符化测试(Step3"特判降级"的前置钉子)。
///
/// 背景:GetIntradayUnwindInterests(盘中:平仓前剩余×实际比例)与
/// CalcEodPostCloseSettleInterestsEOD平仓后收盘:平仓后剩余×恒1)是同一经济事件
/// (部分平仓)的两套传参语义,靠 GetInterests 内 mode2 无条件覆盖 / mode9 全平兜底粘合。
/// 本测试钉死当前行为,使后续特判降级/语义重构有回归网:
/// ① 复利×mode2closePrincipal(特判产物)是 CalcDailyCompoundInterest 的重放本金——
/// 两入口 closePosiNotionalValue 均为实际平掉额 → InterestAmount 必须相等;
/// ② 单利×mode2CalcDailySimpleInterest 消费的是 posiPrincipal×closePercent——
/// 盘中(平仓前×比例) vs EOD(剩余×1) 数值口径可能不同,本测试【记录现状】(见各断言注释);
/// ③ mode9 全平(posi=0):兜底覆盖生效,结息额非零。
///
/// 数据基建复用 GetInterestsUnitTest_T0 的构建器口径(T+04/27起息,"11"算头算尾)。
/// </summary>
[TestClass]
public class GetInterestsEntrySemanticsTest
{
private const decimal Principal = 1000m;
private const decimal FixedRate = 0.01m;
private const decimal FloatRate = 0.001m;
private const int AnnualDays = 365;
private const int ResetPeriod = 3;
private static readonly DateTime TradeDate = new(2026, 4, 27);
private static readonly DateTime StartDate = new(2026, 4, 27);
private static readonly DateTime ExerciseDate = new(2027, 4, 27);
private static readonly DateTime UnwindDate = new(2026, 4, 30);
// 平仓前剩余 1000,平掉 30%(300),收盘后剩余 700
private const decimal PreClose = 1000m;
private const decimal Closed = 300m;
private const decimal Remaining = 700m;
private const decimal ClosePercent = 0.3m;
#region Stub(浮动利率内存取价,与 T0 同款)
private sealed class StubSwapDealService : SwapDealService
{
private readonly IReadOnlyDictionary<DateTime, double> _floatRates;
public StubSwapDealService(OptUserInfo optUser, IReadOnlyDictionary<DateTime, double> floatRates) : base(optUser)
{
_floatRates = floatRates;
}
protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate)
{
if (!string.Equals(underlyingCode, "FR007", StringComparison.OrdinalIgnoreCase)) { rate = 0; return false; }
if (_floatRates.TryGetValue(valueDate.Date, out rate)) return true;
rate = 0;
return false;
}
}
private static SwapDealService CreateService() => new StubSwapDealService(
new OptUserInfo(0, nameof(GetInterestsEntrySemanticsTest), OptUserFrom.UnitTest),
new Dictionary<DateTime, double>
{
[new DateTime(2026, 4, 27)] = (double)FloatRate,
[new DateTime(2026, 4, 28)] = (double)FloatRate,
[new DateTime(2026, 4, 29)] = (double)FloatRate,
[new DateTime(2026, 4, 30)] = (double)FloatRate,
});
#endregion
#region 数据构建(T0 口径)
private static trade CreateTrade()
{
var extend = new trade_extend
{
TradeId = 1,
ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson
{
AnnualDays = AnnualDays,
InterestCalcMode = "11", // 算头算尾
SettlementRules = 0
})
};
return new trade
{
id = 1, TradeNumber = "UT-INT-ENTRY-SEMANTICS", ClientId = 999998,
TradeType = "收益互换", TradeDate = TradeDate, StartDate = StartDate,
ExerciseDate = ExerciseDate, TradeStatus = "确认成交", ValidState = "Valid",
trade_extend = extend
};
}
private static swap_position CreatePosition(InterestModeEnum mode, InterestTypeEnum interestType, bool floating = false)
{
var intervalModels = new List<IntervalModel>
{
new IntervalModel { Date = ExerciseDate, Rate = FixedRate, Settlement = 0 }
};
return new swap_position
{
id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown,
InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = (int)mode,
InterestRateDefault = FixedRate, InterestPrincipalFix = Principal,
PosiStartDate = StartDate, PosiMatuirityDate = ExerciseDate,
IsInitial = true, Invalid = false, InterestType = (int)interestType,
IsAnnualized = true, interest_rest_days = ResetPeriod, interest_rule = 0,
FloatRateUnderlyingCode = floating ? "FR007" : null,
InterestSwapInterval = JsonConvert.SerializeObject(intervalModels)
};
}
private static eod_swap_position CreatePreEod(decimal interestSum, decimal principal)
=> new()
{
id = 1, SwapTradeId = 1, PositionId = 1001, ValueDate = new DateTime(2026, 4, 29),
ClientId = 999998, FloatRate = FloatRate, TdInterestPrincipal = principal,
PosiNotionalValue = principal, InterestIncomeSum = interestSum, InterestProfitSum = interestSum
};
#endregion
/// <summary>
/// 复利×mode2×部分平仓30%:钉住两入口【当前】结息口径(2026-08-14 实测,字符化)。
///
/// 实测(closePrincipal 特判两边均=平掉额300,但消费路径不同):
/// 盘中 = 0.036164835616 —— CalcDailyCompoundInterest 以 closePosi(300) 全程重放 [4/27,4/30]
/// EOD = 0.059041913305 —— InitSwapDealInterest closePercent==1 分支:
/// preEod.InterestIncomeSum(0.05 全腿待实现) + amountAtEnd(0.036165) - amountAtPrevEod(0.027123)。
///
/// ⚠️ 两值不等 = 已观察到的口径分歧(同一经济事件两种结息额),非断言失败项;
/// 待业务裁决哪个口径正确前,本测试锁死两值防意外漂移。裁决后改断言为"相等"或删除错方。
/// </summary>
[TestMethod]
public void 复利_mode2_部分平仓_双入口口径钉住现状()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.复利, floating: true);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind(
td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions,
PreClose, PreClose, 0m, Closed, ClosePercent,
(int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null));
var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, Remaining, Remaining, 0m, Closed, 1m,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, intraday.Count);
Assert.AreEqual(1, eodPostClose.Count);
Console.WriteLine($"[复利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}");
// 钉住两入口各自的当前值(容差 1e-9 级,防任何实现漂移)
Assert.AreEqual(0.036164835616m, intraday[0].InterestAmount, 0.000000001m,
"盘中口径:closePosi(平掉额300) 全程重放利息。此值变化=盘中复利口径漂移");
Assert.AreEqual(0.059041913305m, eodPostClose[0].InterestAmount, 0.000000001m,
"EOD口径:preEod待实现(0.05) + 平掉额末段增量(0.009042)。此值变化=EOD平仓后收盘复利口径漂移");
}
/// <summary>
/// 单利×mode2×部分平仓30%:记录两入口当前口径(快照×比例 vs 重放基数差异面)。
/// 单利消费 posiPrincipal×closePercent:盘中 1000×0.3 vs EOD 700×1 —— 若两值不等,
/// 这是当前系统的已知口径差异面(非断言失败项),数值以 Console 留档,供特判降级时对照。
/// </summary>
[TestMethod]
public void 单利_mode2_部分平仓_双入口口径留档()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind(
td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions,
PreClose, PreClose, 0m, Closed, ClosePercent,
(int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null));
var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, Remaining, Remaining, 0m, Closed, 1m,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, intraday.Count);
Assert.AreEqual(1, eodPostClose.Count);
Console.WriteLine($"[单利mode2] 盘中 InterestAmount={intraday[0].InterestAmount} / EOD={eodPostClose[0].InterestAmount}");
Console.WriteLine($"[单利mode2] TdInterestAmount: 盘中={intraday[0].TdInterestAmount} / EOD={eodPostClose[0].TdInterestAmount}");
// 钉住"两入口非零"这一最低限度事实;数值差异本身是记录项,不是失败项
Assert.IsTrue(intraday[0].InterestAmount != 0m, "盘中单利结息额不应为0");
Assert.IsTrue(eodPostClose[0].InterestAmount != 0m, "EOD单利结息额不应为0");
}
/// <summary>
/// mode9 全平(EODposi=0):特判兜底触发 closePrincipal=closePosiNotionalValue(实际平掉额),
/// 结息额非零。若兜底被删,closePrincipal=0×1=0 → 结息额归零 → 本断言红。
/// </summary>
[TestMethod]
public void 复利_mode9_全平_兜底覆盖生效结息额非零()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.标的期初全价, InterestTypeEnum.复利, floating: true);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { position };
// 全平:剩余=0,平掉=全部 1000
var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate,
eodPositions, positions, 0m, 0m, 0m, PreClose, 1m,
(int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose,
add: true, settment: false, newCalcLast: false, closeList: null);
Assert.AreEqual(1, result.Count);
Console.WriteLine($"[复利mode9全平] InterestAmount={result[0].InterestAmount}");
Assert.IsTrue(result[0].InterestAmount != 0m,
"mode9 全平时 posi=0,兜底必须以 closePosiNotionalValue(实际平掉额) 为结息本金,结息额非零(兜底钉子)");
}
#region CalcEodPostCloseSettleInterests 接缝映射钉子
/// <summary>
/// 参数捕获 stub:拦下 CalcSwapInterests 的全部实参,不触库、不真算。
/// </summary>
private sealed class CalcSwapInterestsCapture : TestableSwapEodPositionService
{
public CalcSwapInterestsCapture() : base(nameof(GetInterestsEntrySemanticsTest)) { }
public List<swap_flow_event> CapturedCloseList = null;
public bool CapturedTdClose;
public int CapturedEventType;
public decimal CapturedPosiNotional;
public decimal CapturedClosePosiNotional;
public decimal CapturedClosePercent;
public decimal CapturedOrginPv;
public bool CapturedAdd;
public bool CapturedSettment;
public bool CapturedNewCalcLast;
public int CallCount;
protected override List<swap_flow_event> CalcSwapInterests(
trade td, trade_extend tradeExtend,
DateTime valueDate, DateTime unwindDate,
List<eod_swap_position> eodPositions, List<swap_position> positions,
decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue,
decimal closePosiNotionalValue, decimal closePrecent,
int eventType, bool tdClose,
decimal orginPv,
bool add = false, bool settment = true, bool newCalcLast = false,
List<swap_flow_event> closeList = null)
{
CallCount++;
CapturedTdClose = tdClose; CapturedEventType = eventType;
CapturedPosiNotional = posiNotionalValue; CapturedClosePosiNotional = closePosiNotionalValue;
CapturedClosePercent = closePrecent; CapturedOrginPv = orginPv;
CapturedAdd = add; CapturedSettment = settment; CapturedNewCalcLast = newCalcLast;
CapturedCloseList = closeList;
return new List<swap_flow_event>();
}
public List<swap_flow_event> ExposedEodPostCloseSettle(InterestCalcRequest req)
=> CalcEodPostCloseSettleInterests(req);
}
/// <summary>
/// 钉死 InterestCalcRequest.EodPostCloseSettle 工厂 → CalcEodPostCloseSettleInterests →
/// CalcSwapInterests 的位置参数转发契约。这段转发是位置传参最易错位的环节
/// posiNotionalValue/closePosiNotionalValue/orginPv 三个相邻同型 decimal,编译器不查错位),
/// 任何映射改动(含将来删 needPrice/grossPrice 死参数)都必须保持本断言绿。
/// </summary>
[TestMethod]
public void EOD平仓后收盘_工厂到接缝_参数映射钉死()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利);
var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose);
var positions = new List<swap_position> { position };
var stub = new CalcSwapInterestsCapture();
var req = InterestCalcRequest.EodPostCloseSettle(
td, td.trade_extend, UnwindDate, UnwindDate,
new List<eod_swap_position> { preEod }, positions,
remainingNotionalAfterClose: Remaining, remainingLongNotional: Remaining, remainingShortNotional: 0m,
closedNotional: Closed,
eventType: (int)SwapEventTypeEnum.平仓, tdClose: false,
orginPv: PreClose, add: true, newCalcLast: false);
stub.ExposedEodPostCloseSettle(req);
Assert.AreEqual(1, stub.CallCount, "默认实现应恰好调用一次 CalcSwapInterests(虚接缝兼容既有测试替身)");
Assert.AreEqual(Remaining, stub.CapturedPosiNotional, "posiNotionalValue 位 = 平仓后剩余(700)——语义核心,错位即红");
Assert.AreEqual(Closed, stub.CapturedClosePosiNotional, "closePosiNotionalValue 位 = 实际平掉额(300)");
Assert.AreEqual(1m, stub.CapturedClosePercent, "closePrecent 恒 1(全额结息)");
Assert.AreEqual((int)SwapEventTypeEnum.平仓, stub.CapturedEventType);
Assert.IsFalse(stub.CapturedTdClose);
Assert.AreEqual(PreClose, stub.CapturedOrginPv, "orginPv 位 = 上一日终本金——与相邻 decimal 最易错位处");
Assert.IsTrue(stub.CapturedAdd);
Assert.IsFalse(stub.CapturedSettment, "settment=false:走盘中重放算法(EOD平仓后收盘复用重放)");
Assert.IsFalse(stub.CapturedNewCalcLast);
Assert.IsNull(stub.CapturedCloseList, "该场景不传 closeList");
}
#endregion
}
}