diff --git a/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs b/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs index ef5a8622..bb9af66d 100644 --- a/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs +++ b/UnitTestProject/Modules/SwapModule/DividendEodNoDoubleCountTest.cs @@ -247,5 +247,45 @@ namespace YLErp.Modules.SwapModule AssertDecimalEqual(0m, r2.TdPosiDividend, 0.01m, "登记日全平 EOD 不计提当日(PosiQuantity=0,正确)"); AssertDecimalEqual(0m, r2.PosiDividendSum, 0.01m, "全平后 PosiDividendSum=0"); } + + /// + /// 【死代码删除的边界规格】脏数据(OriginalStockEqvNotional=null / PosiNetPrice=0)不得让 + /// UpdateEodPosition 崩溃,且分红产出与正常数据完全一致。 + /// 背景:这两个字段在 UpdateEodPosition 内的唯一消费点是历史遗留死代码 + /// (originNotional→totalPayment 全历史重算,结果从未被使用,2026-08 论证后删除)—— + /// 删除前该脏数据会在 EOD 抛 InvalidOperationException/除零;删除后是设计内行为。 + /// 本测试同时钉住:删除后输出等价(与同输入正常数据路径一致)。 + /// + [TestMethod] + public void 脏数据边界_死代码涉及字段_不影响EOD分红产出() + { + // 正常数据基准 + var eodSvcClean = new EodSvcStub(BondPayments()); + var tdClean = CreateTrade(); + var positionClean = CreatePosition(); + var initialEod = CreateInitialEod(); + var d1 = new DateTime(2026, 1, 6); + var d2 = new DateTime(2026, 1, 7); + var r1Clean = eodSvcClean.ExecuteCopyEodPosition(initialEod, tdClean, d1, StartDate); + var r2Clean = eodSvcClean.ExecuteUpdateEodPosition(positionClean, r1Clean, tdClean, d2, d1, + new List { CloseEvent(InitialQty, r1Clean.PosiDividendSum, d2) }); + + // 脏数据:死代码涉及的两字段置脏(活路径零消费,见方法内 grep 论证) + var eodSvcDirty = new EodSvcStub(BondPayments()); + var tdDirty = CreateTrade(); + tdDirty.OriginalStockEqvNotional = null; // 死代码 (decimal) 强转崩溃点 + var positionDirty = CreatePosition(); + positionDirty.PosiNetPrice = 0m; // 死代码除零崩溃点 + var r1Dirty = eodSvcDirty.ExecuteCopyEodPosition(initialEod, tdDirty, d1, StartDate); + var r2Dirty = eodSvcDirty.ExecuteUpdateEodPosition(positionDirty, r1Dirty, tdDirty, d2, d1, + new List { CloseEvent(InitialQty, r1Dirty.PosiDividendSum, d2) }); + + // 脏数据不崩 + 输出与正常数据逐字段一致 + AssertDecimalEqual(r2Clean.TdPosiDividend, r2Dirty.TdPosiDividend, 0.0001m, "TdPosiDividend 不受脏字段影响"); + AssertDecimalEqual(r2Clean.TdCloseDividend, r2Dirty.TdCloseDividend, 0.0001m, "TdCloseDividend 不受脏字段影响"); + AssertDecimalEqual(r2Clean.PosiDividendSum, r2Dirty.PosiDividendSum, 0.0001m, "PosiDividendSum 不受脏字段影响"); + AssertDecimalEqual(r2Clean.RealizedDividend, r2Dirty.RealizedDividend, 0.0001m, "RealizedDividend 不受脏字段影响"); + Console.WriteLine($"[脏数据边界] 正常={r2Clean.PosiDividendSum} 脏数据={r2Dirty.PosiDividendSum}(应相等且不抛异常)"); + } } } diff --git a/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs index 078ef4b6..f614e93c 100644 --- a/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs +++ b/UnitTestProject/Modules/SwapModule/GetInterestsEntrySemanticsTest.cs @@ -54,6 +54,10 @@ namespace YLErp.Modules.SwapModule rate = 0; return false; } + // 离线自洽:本测试场景无历史已结利息,等价于此前"空库查询返回 0"的行为, + // 使复利路径(GetConsumedInterest)不再依赖数据库连通(YLErp_UNIT_TEST_SKIP_INITIALIZATION=1 可跑)。 + public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) + => 0m; } private static SwapDealService CreateService() => new StubSwapDealService( @@ -121,18 +125,17 @@ namespace YLErp.Modules.SwapModule #endregion /// - /// 复利×mode2×部分平仓30%:钉住两入口【当前】结息口径(2026-08-14 实测,字符化)。 + /// 复利×mode2×部分平仓30%:【同请求形状⇒同额】oracle(契约目标语义,修复落地时的现成回归网)。 /// - /// 实测(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)。 - /// - /// ⚠️ 两值不等 = 已观察到的口径分歧(同一经济事件两种结息额),非断言失败项; - /// 待业务裁决哪个口径正确前,本测试锁死两值防意外漂移。裁决后改断言为"相等"或删除错方。 + /// 修复前(b01b485e 钉住的分歧):盘中 0.036165(平掉额全程重放=确认书公式)vs + /// EOD 0.059042(恒1 掉进全平专属分支,全腿待实现+末段增量,无契约依据,重算结果被丢弃)。 + /// 修复(契约修复§六):EOD 普通当日平仓重算(autoSwap=false)改传 Intraday 形状 + /// (平仓前剩余+实际平掉额+真实比例),部分平仓不再进 closePrecent==1 分支。 + /// 依据:项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md(契约公式唯一确定应结=平掉额×全程参考利率)。 + /// 观察日(autoSwap=true)路径仍走 EodPostCloseSettle(剩余+恒1),:1220 为其设计语义,不在本断言范围。 /// [TestMethod] - public void 复利_mode2_部分平仓_双入口口径钉住现状() + public void 复利_mode2_部分平仓_双入口契约口径一致() { var td = CreateTrade(); var position = CreatePosition(InterestModeEnum.合约名义本金规模, InterestTypeEnum.复利, floating: true); @@ -145,8 +148,9 @@ namespace YLErp.Modules.SwapModule PreClose, Closed, ClosePercent, (int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null)); + // 契约目标形状(修复暂缓中,生产仍传 剩余+恒1):与盘中一致(平仓前剩余 1000 + 平掉额 300 + 真实比例 0.3) var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, - eodPositions, positions, Remaining, Closed, 1m, + eodPositions, positions, PreClose, Closed, ClosePercent, (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, add: true, settment: false, newCalcLast: false, closeList: null); @@ -154,11 +158,35 @@ namespace YLErp.Modules.SwapModule 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平仓后收盘复利口径漂移"); + // 契约 oracle:两入口同请求形状必须同额(=确认书公式"平掉额×全程参考利率") + Assert.AreEqual(intraday[0].InterestAmount, eodPostClose[0].InterestAmount, 0.000000001m, + "GetInterests 层契约目标:同请求形状必须同额(生产入口修复暂缓中,本断言为落地时的现成回归网)"); + // 手算锚点(300×[(1+0.011×3/365)×(1+0.011×1/365)−1],与裁决文档§二玩具参数一致) + Assert.AreEqual(0.036165m, Math.Round(intraday[0].InterestAmount, 6, MidpointRounding.AwayFromZero), + "盘中重放=契约公式手算锚点 0.036165"); + } + + /// + /// 【回归钉子】复利×mode2×部分平仓:观察日路径(EodPostCloseSettle 剩余+恒1)保持设计语义不回退。 + /// 修复只改 autoSwap=false 分支;观察日恒1 全量结息是 :1220 分支的设计意图(结现),锁死其当前值。 + /// + [TestMethod] + public void 复利_mode2_部分平仓_观察日恒1语义保持() + { + 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 observationDay = 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, observationDay.Count); + Assert.AreEqual(0.059041913305m, observationDay[0].InterestAmount, 0.000000001m, + "观察日(autoSwap=true)路径:剩余+恒1 的全平分支为其设计语义(结现),修复不得改变此值"); } /// @@ -180,8 +208,9 @@ namespace YLErp.Modules.SwapModule PreClose, Closed, ClosePercent, (int)SwapEventTypeEnum.平仓, tdClose: true, orginPv: PreClose, add: true, newCalcLast: false, closeList: null)); + // 契约目标形状(修复暂缓中,生产仍传 剩余+恒1):与盘中一致(平仓前剩余 1000 + 平掉额 300 + 真实比例 0.3) var eodPostClose = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, - eodPositions, positions, Remaining, Closed, 1m, + eodPositions, positions, PreClose, Closed, ClosePercent, (int)SwapEventTypeEnum.平仓, tdClose: false, orginPv: PreClose, add: true, settment: false, newCalcLast: false, closeList: null); @@ -189,14 +218,15 @@ namespace YLErp.Modules.SwapModule 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.AreEqual(intraday[0].InterestAmount, eodPostClose[0].InterestAmount, 0.000000001m, + "GetInterests 层契约目标:单利×mode2 同请求形状必须同额(生产入口修复暂缓中)"); 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 → 结息额归零 → 本断言红。 + /// mode9 全平(契约目标形状:平仓前剩余=平掉额=1000、比例恒1): + /// 结息额非零且=全平语义(:1220 全平分支:待实现+末段增量,尾差一次带走——裁决§五.2 维持)。 /// [TestMethod] public void 复利_mode9_全平_兜底覆盖生效结息额非零() @@ -207,16 +237,16 @@ namespace YLErp.Modules.SwapModule var eodPositions = new List { preEod }; var positions = new List { position }; - // 全平:剩余=0,平掉=全部 1000 + // 全平:平仓前剩余=平掉=1000,比例恒1(全平专属分支) var result = CreateService().GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, - eodPositions, positions, 0m, PreClose, 1m, + eodPositions, positions, PreClose, 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(实际平掉额) 为结息本金,结息额非零(兜底钉子)"); + "mode9 全平:结息本金=平掉额(1000),结息额非零(全平语义钉子)"); } #region CalcEodPostCloseSettleInterests 接缝映射钉子 diff --git a/YLErpDAL/Modules/SwapModule/SwapDealService.cs b/YLErpDAL/Modules/SwapModule/SwapDealService.cs index 5f29449c..229f4c1b 100644 --- a/YLErpDAL/Modules/SwapModule/SwapDealService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapDealService.cs @@ -1219,6 +1219,12 @@ namespace YLErp.Modules.SwapModule consumedInterest, resetCarryInterest); if (preEodPosition.id != 0 && closePrecent == 1m) { + // 【全平专属分支触发标记】(快速定位):设计意图=真全平(尾差一次带走)与观察日恒1全额结息。 + // ⚠️ 契约修复暂缓期间,普通部分平仓经 EOD 恒1惯例【仍会进入本分支】(重算结果已被裁决 + // 证为不落库/不动钱/不进资金,零生产后果);修复落地后部分平仓不再进入——本行日志届时 + // 兼作落地验证哨兵(部分平仓出现在此=修复未生效/被回退)。 + Logger.Info($"[利息-全平专属分支] tradeId={td.id} posiId={position.id} valueDate={valueDate:yyyy-MM-dd} " + + $"closePrecent={closePrecent} preEod.InterestIncomeSum={preEodPosition.InterestIncomeSum}"); // 最终全平只重放上一日终之后的新增利息;历史部分平仓的两位结算尾差已在日终待实现中。 // InterestAmount 是本次最终应结金额;TdInterestAmount 是不按关闭比例缩放的参考累计值。 // 二者在全平时都以上一日 InterestIncomeSum 为起点,保证之前攒下的尾差最后一次带走。 diff --git a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs index d96ed571..f2f06971 100644 --- a/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs +++ b/YLErpDAL/Modules/SwapModule/SwapEodPositionService.cs @@ -106,9 +106,11 @@ namespace YLErp.Modules.SwapModule /// /// 【EOD 当日有平仓后的收盘结息】显式入口——原 SaveAutoEodWithCloseInterestPosition 直调 /// CalcSwapInterests(settment:false) 的具名封装(2026-08 显式化重构)。 + /// 语义契约见 InterestCalcRequest.EodPostCloseSettle 工厂注释(平仓后剩余 + 实际平掉额 + 恒1全额结息, /// 触发 GetInterests 内 mode2/mode9 本金修正)。计息走 CalcUnwindInterest 全区间重放。 /// 默认实现仍经 CalcSwapInterests 转发,保持既有测试替身对该虚接缝的拦截不变。 + /// (契约修复§六暂缓中:落地时 autoSwap=false 分支改 Intraday 形状,见裁决文档与调用点注释。) /// protected virtual List CalcEodPostCloseSettleInterests(InterestCalcRequest req) => CalcSwapInterests(req.Td, req.TradeExtend, req.ValueDate, req.UnwindDate, req.EodPositions, req.Positions, @@ -1321,6 +1323,15 @@ namespace YLErp.Modules.SwapModule var calcLast = tradeExtend?.InterestCalcMode?.EndsWith("1") ?? true; // 显式入口:平仓后剩余本金 + 实际平掉额 + 恒1全额结息(语义见 InterestCalcRequest.EodPostCloseSettle)。 // 该组合触发 GetInterests 内共享计息器的模式2/9本金修正(见其"根因位置"注释,勿删)。 + // ⚠️ 契约修复暂缓中(2026-08-16 撤回):按裁决文档§六,autoSwap=false 本应改传 Intraday 形状 + // (平仓前剩余+真实比例),但该变更影响快照种子(TdInterestPrincipal等),黄金回放验收门未过前不落地; + // 落地时见 项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md §六 与已就绪的回归网 + //(GetInterestsEntrySemanticsTest.复利_mode2_部分平仓_双入口契约口径一致)。 + // 口径选择常驻记录(快速定位第一入口):出问题先看这行确认当日本次事件的金额输入,再顺着 + // SwapCalcTrace 分段过程日志追计算;autoSwap=观察日结现路径。 + Log.Info($"[EOD平仓后收盘结息] tradeId={td.id} valueDate={valueDate:yyyy-MM-dd} autoSwap={autoSwap} " + + $"口径=恒1全额结息(历史行为,契约修复暂缓) " + + $"oriPosi(平仓前)={oriPosiNotionalValue} posi(剩余)={posiNotionalValue} close(平掉)={closeNational}"); var interests = CalcEodPostCloseSettleInterests(InterestCalcRequest.EodPostCloseSettle( td, td.trade_extend, valueDate, valueDate, preEodPositions, positions, posiNotionalValue, closeNational, @@ -1762,6 +1773,11 @@ namespace YLErp.Modules.SwapModule curretEod.TdPosiDividend = DividendCalc.AfterTax(payment, tax); } curretEod.PosiDividendSum = eod.PosiQuantity > 0 ? Math.Round(eod.PosiDividendSum + curretEod.TdPosiDividend, 2) : 0; + // 分红递推过程常驻记录(快速定位):窗口/数量/税率/当日新计/累计前后值—— + // 配合 BondPaymentService 的[分红-登记日口径]窗口命中日志,构成"命中哪些登记日→算出多少→账滚到多少"全链 + Log.Info($"[分红-EOD计提Copy] tradeId={td.id} posiId={eod.PositionId} valueDate={valueDate:yyyy-MM-dd} " + + $"window=({eod.ValueDate:yyyy-MM-dd},{valueDate:yyyy-MM-dd}] qty={curretEod.PosiQuantity} tax={tax} " + + $"TdPosiDividend={curretEod.TdPosiDividend} PosiDividendSum {eod.PosiDividendSum}->{curretEod.PosiDividendSum}"); curretEod.PosiQuantity = eod.PosiQuantity; if (curretEod.PosiStatus == 1) { @@ -1834,11 +1850,11 @@ namespace YLErp.Modules.SwapModule int shortRatio = DirectionRatio.LongShort(eod.PositionType); int directionRatio = DirectionRatio.ReceivePay(eod.PosiDirection); var price = GetSwapValuationPrice(eod.UnderlyingCode, dealDate, out decimal vobp); - var todayConsumedDividend = CalcConsumedDividend(curretEod, unwindEvents); - var originNotional = (decimal)td.OriginalStockEqvNotional / swapPosition.PosiNetPrice; - decimal totalPayment = CalcBondPayment(curretEod.UnderlyingCode, td.StartDate.Value, valueDate, (decimal)originNotional, shortRatio, directionRatio); + // 历史遗留死代码已删(2026-08-16,论证+边界测试见 DividendEodNoDoubleCountTest.脏数据边界_*): + // todayConsumedDividend / originNotional / totalPayment / totalInterest 自 0910969e(2026-07-02 + // 改递推式) 起计算结果从未被消费,仅残留一次全历史 CalcBondPayment 只读查询+日志副作用, + // 且构成脏数据(OriginalStockEqvNotional=null/PosiNetPrice=0)下的 EOD 崩溃点。回退=git revert 本提交。 decimal tax = um.ValueAddedTax ?? 0; - decimal totalInterest = DividendCalc.AfterTaxRaw(totalPayment, tax); SetPriceInfoByFlowEvent(eod, curretEod, unwindEvents, swapPosition); curretEod.dv01 = Dv01Helper.CalcDv01(eod.UnderlyingCode, curretEod.PosiQuantity, eod.PosiDirection, eod.PositionType, vobp); curretEod.UnderlyingPrice = price; @@ -1871,6 +1887,11 @@ namespace YLErp.Modules.SwapModule { curretEod.PosiDividendSum = 0; } + // 分红递推过程常驻记录(快速定位):当日事件路径含实现扣减(前日+新计-当日实现) + Log.Info($"[分红-EOD计提Update] tradeId={td.id} posiId={eod.PositionId} valueDate={valueDate:yyyy-MM-dd} " + + $"window=({eod.ValueDate:yyyy-MM-dd},{valueDate:yyyy-MM-dd}] qty={curretEod.PosiQuantity} tax={tax} " + + $"TdPosiDividend={curretEod.TdPosiDividend} TdCloseDividend={curretEod.TdCloseDividend} " + + $"PosiDividendSum {eod.PosiDividendSum}->{curretEod.PosiDividendSum}"); EodPnlCalculator.SetFloatingRealizedPnl(curretEod); curretEod.SwapPositionValue -= curretEod.TdCloseDividend; @@ -1893,20 +1914,6 @@ namespace YLErp.Modules.SwapModule return curretEod; } - private decimal CalcConsumedDividend(eod_swap_position curretEod, List events) - { - decimal consumedDividend = 0; - - List swapEventTypes = new List() { (int)SwapEventTypeEnum.互换, (int)SwapEventTypeEnum.自动互换 }; - //这里要剔除掉平仓产生的分红 - consumedDividend = events - .Where(x => x.SwapTradeId == curretEod.SwapTradeId - && swapEventTypes.Contains(x.EventType) - && x.DataState == (int)SwapFlowDateStateEnum.完成) - .Sum(s => s.DividendIn); - return consumedDividend; - } - /// /// 根据开平仓事件算价格及后付费用 /// diff --git a/项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md b/项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md index 792c9f8b..aded0c2b 100644 --- a/项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md +++ b/项目文档/双入口口径裁决-复利mode2部分平仓-20260816.md @@ -1,7 +1,13 @@ # 裁决申请:复利×mode2×部分平仓 双入口结息额分歧 -> 2026-08-16 | 来源:`GetInterestsEntrySemanticsTest.复利_mode2_部分平仓_双入口口径钉住现状`(b01b485e 实测钉住) -> 状态:**待业务裁决**——同一经济事件,两个入口算出两种结息额,当前测试锁死两值防漂移。 +> 2026-08-16 | 来源:`GetInterestsEntrySemanticsTest`(b01b485e 实测钉住) +> 状态:**§六修复暂缓(2026-08-16 撤回,待黄金回放验收门)**——口径依据=确认书公式(契约唯一确定,无可裁决项); +> 修复影响快照种子(TdInterestPrincipal/TdInterestIncome 等下游分支),须全量基线+黄金回放通过后落地, +> 而 96 库网络未恢复导致验收门无法执行。落地材料已全部就绪:方案(本文件§六)+回归网 +> (GetInterestsEntrySemanticsTest.复利_mode2_部分平仓_双入口契约口径一致——同请求形状等价 oracle+手算锚点) +> +观察日保持钉子+[EOD平仓后收盘结息]/[利息-全平专属分支]常驻日志(落地验证哨兵)。 +> 特判退役(mode2覆盖/mode9兜底)**另案暂缓**:copy 路径(部分平仓后日结转)依赖特判维持 closePrincipal=剩余全额, +> 其 closePercent=剩余比例语义需先理清,留作独立重构。 ## 一、分歧事实(生产代码实测,已钉住)