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
}
}