test(swap): 铺 §7-1 守恒不变量护栏,守 EOD平仓后收盘×部分平仓 裸格(§6 空洞1)

新增 4 个免库纯内存测试(GetInterestsEntrySemanticsTest):①部分平仓 剩余持仓前递且 期初=剩余+平掉;②全平 剩余前递=0;③两次部分平仓 逐日守恒 期初-剩余前递=平掉;④ClosePercentMath 多次平仓累计比例=1-∏(1-各次剩余口径)纯数学。

守恒断言落在 preEod.PosiNotionalValue(剩余前递)而非 InterestPrincipal——后者在 InitSwapDealInterest:1164 后被利息算法重赋值(1197 复利特判/1266 单利路径),语义随入口/模式变,非稳健观测点;剩余前递由 CalcUnwindInterest:1084 在 preEod.id==0 时写入,与利息算法无关,最稳健。

用途:作为 GetInterests 早路由(Tier A)合入的前置护栏——funding-leg(mode2)不触发早路由 continue,故本类任何回归都直接暴露早路由对 EOD平仓后收盘路径的破坏。验证:YLErp_UNIT_TEST_SKIP_INITIALIZATION=1 dotnet test --filter Name~守恒 全过;整类仅 2 个旧复利测试因无 96 库失败,与本次无关。
This commit is contained in:
hjhan
2026-08-16 11:17:37 +08:00
parent 73264a30e2
commit c94a3e8c97
@@ -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,故亦是早路由改动护栏。
/// <summary>
/// 建一个"无历史 eod"快照(id==0),使引擎把本次剩余持仓写入 preEod.PosiNotionalValue。
/// </summary>
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
};
/// <summary>
/// §7-1 守恒①:EOD平仓后收盘×部分平仓,引擎把剩余持仓(700)前递进 preEod.PosiNotionalValue
/// 且 期初 = 前递剩余(码算) + 平掉额(输入) = 1000。
/// 守 2035e1df 裸格(§6 空洞1):若 EOD 入口把前递值误写成平掉额/期初,守恒等式即破。
/// </summary>
[TestMethod]
public void EOD平仓后收盘_部分平仓_守恒_剩余前递且期初等于剩余加平掉额()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum.);
var preEod = NewPreEod(Remaining); // 无历史 eod → 引擎写回剩余
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { 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);本金口径不守恒则利息算错");
}
/// <summary>
/// §7-1 守恒②:EOD平仓后收盘×全平,剩余持仓前递=0(清仓)。守全平非零边界的互补面。
/// </summary>
[TestMethod]
public void EOD平仓后收盘_全平_守恒_剩余前递归零()
{
var td = CreateTrade();
var position = CreatePosition(InterestModeEnum., InterestTypeEnum.);
var preEod = NewPreEod(0m);
var eodPositions = new List<eod_swap_position> { preEod };
var positions = new List<swap_position> { 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)");
}
/// <summary>
/// §7-1 守恒③(逐日):两次部分平仓,Day2 剩余前递 = 当日剩余(码算),且 期初 - 前递剩余 = 平掉额,
/// 构成跨日携带链守恒。Day1 期初1000→平300剩700Day2 期初700→平210剩490;累计平掉510+剩余490=1000。
/// </summary>
[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<eod_swap_position> { preEod1 }, new List<swap_position> { 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<eod_swap_position> { preEod2 }, new List<swap_position> { 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);跨日携带链本金不守恒则利息算错");
}
/// <summary>
/// §7-1 守恒④(纯数学,ClosePercentMath):多次平仓累计占期初比例 = 1 - ∏(1 - 各次剩余口径)。
/// 初次占期初30%(平300/名义1000)→剩余口径0.3;二次占期初50%(平350/剩余700)→剩余口径0.5
/// 累计平掉 = 1 - 0.7×0.5 = 0.65。验证 ClosePercentMath 双口径换算在多次平仓下不漂移。
/// </summary>
[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
}
}