using Newtonsoft.Json; using YLErp.DBModels.Enums; namespace YLErp.Modules.SwapModule { /// /// GetInterests 双显式入口语义字符化测试(Step3"特判降级"的前置钉子)。 /// /// 背景:GetIntradayUnwindInterests(盘中:平仓前剩余×实际比例)与 /// CalcEodPostCloseSettleInterests(EOD平仓后收盘:平仓后剩余×恒1)是同一经济事件 /// (部分平仓)的两套传参语义,靠 GetInterests 内 mode2 无条件覆盖 / mode9 全平兜底粘合。 /// 本测试钉死当前行为,使后续特判降级/语义重构有回归网: /// ① 复利×mode2:closePrincipal(特判产物)是 CalcDailyCompoundInterest 的重放本金—— /// 两入口 closePosiNotionalValue 均为实际平掉额 → InterestAmount 必须相等; /// ② 单利×mode2:CalcDailySimpleInterest 消费的是 posiPrincipal×closePercent—— /// 盘中(平仓前×比例) vs EOD(剩余×1) 数值口径可能不同,本测试【记录现状】(见各断言注释); /// ③ mode9 全平(posi=0):兜底覆盖生效,结息额非零。 /// /// 数据基建复用 GetInterestsUnitTest_T0 的构建器口径(T+0,4/27起息,"11"算头算尾)。 /// [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 _floatRates; public StubSwapDealService(OptUserInfo optUser, IReadOnlyDictionary 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 { [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 { 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 /// /// 复利×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)。 /// /// ⚠️ 两值不等 = 已观察到的口径分歧(同一经济事件两种结息额),非断言失败项; /// 待业务裁决哪个口径正确前,本测试锁死两值防意外漂移。裁决后改断言为"相等"或删除错方。 /// [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 { preEod }; var positions = new List { position }; var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind( td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, PreClose, 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, 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平仓后收盘复利口径漂移"); } /// /// 单利×mode2×部分平仓30%:记录两入口当前口径(快照×比例 vs 重放基数差异面)。 /// 单利消费 posiPrincipal×closePercent:盘中 1000×0.3 vs EOD 700×1 —— 若两值不等, /// 这是当前系统的已知口径差异面(非断言失败项),数值以 Console 留档,供特判降级时对照。 /// [TestMethod] public void 单利_mode2_部分平仓_双入口口径留档() { var td = CreateTrade(); var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利); var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose); var eodPositions = new List { preEod }; var positions = new List { position }; var intraday = CreateService().GetIntradayUnwindInterests(InterestCalcRequest.IntradayUnwind( td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, PreClose, 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, 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"); } /// /// mode9 全平(EOD,posi=0):特判兜底触发 closePrincipal=closePosiNotionalValue(实际平掉额), /// 结息额非零。若兜底被删,closePrincipal=0×1=0 → 结息额归零 → 本断言红。 /// [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 { preEod }; var positions = new List { position }; // 全平:剩余=0,平掉=全部 1000 var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, 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 接缝映射钉子 /// /// 参数捕获 stub:拦下 CalcSwapInterests 的全部实参,不触库、不真算。 /// private sealed class CalcSwapInterestsCapture : TestableSwapEodPositionService { public CalcSwapInterestsCapture() : base(nameof(GetInterestsEntrySemanticsTest)) { } public List 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 CalcSwapInterests( trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, List eodPositions, List positions, decimal posiNotionalValue, decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List 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(); } public List ExposedEodPostCloseSettle(InterestCalcRequest req) => CalcEodPostCloseSettleInterests(req); } /// /// 钉死 InterestCalcRequest.EodPostCloseSettle 工厂 → CalcEodPostCloseSettleInterests → /// CalcSwapInterests 的位置参数转发契约。这段转发是位置传参最易错位的环节 /// (posiNotionalValue/closePosiNotionalValue/orginPv 三个相邻同型 decimal,编译器不查错位), /// 任何映射改动(含将来删 needPrice/grossPrice 死参数)都必须保持本断言绿。 /// [TestMethod] public void EOD平仓后收盘_工厂到接缝_参数映射钉死() { var td = CreateTrade(); var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利); var preEod = CreatePreEod(interestSum: 0.05m, principal: PreClose); var positions = new List { position }; var stub = new CalcSwapInterestsCapture(); var req = InterestCalcRequest.EodPostCloseSettle( td, td.trade_extend, UnwindDate, UnwindDate, new List { preEod }, positions, remainingNotionalAfterClose: Remaining, 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 #region 守恒不变量(§7-1, 免 oracle/免库, 守 EOD平仓后收盘×部分平仓 裸格) // 守恒不变量统一断言在"剩余持仓前递"(preEod.PosiNotionalValue)上:该字段由 CalcUnwindInterest/ // InitSwapDealInterest 在 preEod.id==0 时写入(posiPrincipal),与利息算法(单/复、FR007)无关, // 是最稳健、码算、免库的守恒观测点。期初(orginPv) = 前递剩余 + 平掉额(closePosiNotionalValue) 必须成立。 // 全部内存构造(StubSwapDealService 避库);funding-leg(mode2)不触发早路由 continue,故亦是早路由改动护栏。 /// /// 建一个"无历史 eod"快照(id==0),使引擎把本次剩余持仓写入 preEod.PosiNotionalValue。 /// private static eod_swap_position NewPreEod(decimal carryPrincipal) => new() { id = 0, SwapTradeId = 1, PositionId = 1001, ValueDate = new DateTime(2026, 4, 29), ClientId = 999998, FloatRate = 0m, TdInterestPrincipal = carryPrincipal, PosiNotionalValue = carryPrincipal, InterestIncomeSum = 0.05m, InterestProfitSum = 0.05m }; /// /// §7-1 守恒①:EOD平仓后收盘×部分平仓,引擎把剩余持仓(700)前递进 preEod.PosiNotionalValue, /// 且 期初 = 前递剩余(码算) + 平掉额(输入) = 1000。 /// 守 2035e1df 裸格(§6 空洞1):若 EOD 入口把前递值误写成平掉额/期初,守恒等式即破。 /// [TestMethod] public void EOD平仓后收盘_部分平仓_守恒_剩余前递且期初等于剩余加平掉额() { var td = CreateTrade(); var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利); var preEod = NewPreEod(Remaining); // 无历史 eod → 引擎写回剩余 var eodPositions = new List { preEod }; var positions = new List { position }; var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, Remaining, Closed, 1m, (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, add: false, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, result.Count, "EOD平仓后收盘部分平仓应产生 1 条利息事件"); // 码算:引擎把剩余持仓前递(return 700) Assert.AreEqual(Remaining, preEod.PosiNotionalValue, "EOD平仓后收盘必须把剩余持仓(700)前递进 preEod.PosiNotionalValue;若误写平掉额/期初则守恒破坏"); // 守恒:期初 = 前递剩余(码算) + 平掉额(输入) Assert.AreEqual(PreClose, preEod.PosiNotionalValue + Closed, "期初(orginPv=1000) 必须 = 剩余(700) + 平掉额(300);本金口径不守恒则利息算错"); } /// /// §7-1 守恒②:EOD平仓后收盘×全平,剩余持仓前递=0(清仓)。守全平非零边界的互补面。 /// [TestMethod] public void EOD平仓后收盘_全平_守恒_剩余前递归零() { var td = CreateTrade(); var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利); var preEod = NewPreEod(0m); var eodPositions = new List { preEod }; var positions = new List { position }; var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, eodPositions, positions, 0m, PreClose, 1m, (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, add: false, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, result.Count); Assert.AreEqual(0m, preEod.PosiNotionalValue, "全平后剩余持仓前递必须为 0;非 0 表示平仓未清仓,守恒破坏"); Assert.AreEqual(PreClose, preEod.PosiNotionalValue + PreClose, "全平守恒:期初(1000) = 剩余(0) + 平掉额(1000)"); } /// /// §7-1 守恒③(逐日):两次部分平仓,Day2 剩余前递 = 当日剩余(码算),且 期初 - 前递剩余 = 平掉额, /// 构成跨日携带链守恒。Day1 期初1000→平300剩700;Day2 期初700→平210剩490;累计平掉510+剩余490=1000。 /// [TestMethod] public void EOD平仓后收盘_两次部分平仓_逐日守恒_期初减剩余前递等于平掉额() { var td = CreateTrade(); var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.单利); // Day1:期初1000,平300,剩700 var preEod1 = NewPreEod(PreClose); var result1 = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, new List { preEod1 }, new List { position }, Remaining, Closed, 1m, (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, add: false, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, result1.Count); Assert.AreEqual(Remaining, preEod1.PosiNotionalValue, "Day1 剩余前递应为 700"); // Day2:期初=Day1剩余700,平210,剩490 const decimal day2OrginPv = 700m; const decimal day2Closed = 210m; const decimal day2Remaining = 490m; var preEod2 = NewPreEod(day2OrginPv); // 承载=Day1剩余700 var result2 = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, new List { preEod2 }, new List { position }, day2Remaining, day2Closed, 1m, (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: day2OrginPv, add: false, settment: false, newCalcLast: false, closeList: null); Assert.AreEqual(1, result2.Count); // 码算:Day2 剩余前递=当日剩余(490) Assert.AreEqual(day2Remaining, preEod2.PosiNotionalValue, "Day2 剩余前递=剩余(490,码算值)"); // 逐日守恒:期初 - 剩余前递 = 平掉额(210) Assert.AreEqual(day2Closed, day2OrginPv - preEod2.PosiNotionalValue, "Day2 守恒:期初(700) - 剩余前递(490) 必须 = 平掉额(210);跨日携带链本金不守恒则利息算错"); } /// /// §7-1 守恒④(纯数学,ClosePercentMath):多次平仓累计占期初比例 = 1 - ∏(1 - 各次剩余口径)。 /// 初次占期初30%(平300/名义1000)→剩余口径0.3;二次占期初50%(平350/剩余700)→剩余口径0.5; /// 累计平掉 = 1 - 0.7×0.5 = 0.65。验证 ClosePercentMath 双口径换算在多次平仓下不漂移。 /// [TestMethod] public void 多次平仓_占期初累计比例等于各次剩余口径连乘补数() { var b1 = ClosePercentMath.ToRemainingClosePercent(0.3m, 1000m, 1000m); Assert.AreEqual(0.3m, b1, "初次平仓占期初30% → 剩余口径应为 0.3"); var b2 = ClosePercentMath.ToRemainingClosePercent(0.5m, 700m, 700m); Assert.AreEqual(0.5m, b2, "二次平仓占期初50%(占剩余700) → 剩余口径应为 0.5"); var cumulativeClosed = 1m - (1m - b1) * (1m - b2); Assert.AreEqual(0.65m, cumulativeClosed, 0.0000001m, "多次平仓累计平掉比例必须=各次剩余口径连乘的补数;否则本金口径在多次平仓下分裂"); var back = ClosePercentMath.ToOriginalClosePercent(cumulativeClosed, 1000m, 1000m); Assert.AreEqual(0.65m, back, 0.0000001m, "累计占期初比例反向还原必须一致"); } #endregion } }