diff --git a/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs index b3d1ed58..078ef4b6 100644 --- a/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs +++ b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs @@ -303,5 +303,138 @@ namespace YLErp.Modules.SwapModule } #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 } }