From 4916f7207ddb08788e3d983bcccad96192eacec5 Mon Sep 17 00:00:00 2001 From: hjhan Date: Sun, 9 Aug 2026 11:22:42 +0800 Subject: [PATCH] =?UTF-8?q?test(refactor):=20SwapInterestScenario3And4=20?= =?UTF-8?q?=E8=AF=9A=E5=AE=9E=E5=8C=96=E2=80=94=E2=80=94=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=20return=200m,=20=E4=BB=8E=E4=BA=A4=E6=98=93=E5=BC=80=E5=A7=8B?= =?UTF-8?q?=E6=97=A5=E9=80=90=E6=97=A5=E7=9C=9F=E5=AE=9E=E6=94=B6=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 StubSwapDealService.GetConsumedInterest 的 硬编码(此前注释明写"与生产缺陷态一致", 等于把唯一会出错的机制整个屏蔽, 断言缺陷态输出, 伪绿)。 - 改为复用生产同一口径: 对真实收盘经 PersistFlowEvent 累积的 swap_flow_event (EventType∈{互换,自动互换}, DataState=完成, EventDate<结算日) 求和 —— 数据来自真实代码执行, 非硬编码。 - TestableSwapEodPositionService 新增 FlowEvents 捕获 + override PersistFlowEvent, 供 GetConsumedInterest 真实计算(不连库)。 - 新增 E2EEodService: 从交易开始日(TradeDate)逐日驱动真实 SwapPositionCompose 构建 eod 链, 再把真实 eod 快照喂给平仓; 平仓走生产同一入口 SaveAutoEodWithCloseInterestPosition。 - 全程无硬编码输入: FR007 曲线/名义本金/日期均来自 Excel 场景真源, 计算全走真实代码。 - 验证: 24/24 通过(SKIP_INITIALIZATION); PrepaidPrincipal 追踪测试 2/2 通过; 基类改动无回归。 - 注: ConsumedInterestScenarioTest.CI_008 既有失败(复利重置日本金继承, 与本回合无关, 未触碰该文件), 另议。 --- .../SwapInterestScenario3And4FloatingTest.cs | 254 ++++++++++++------ .../TestableSwapEodPositionService.cs | 19 ++ 2 files changed, 187 insertions(+), 86 deletions(-) diff --git a/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs index 9cca65c5..e06b31a2 100644 --- a/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs +++ b/UnitTestProject/Modules/SwapModule/SwapInterestScenario3And4FloatingTest.cs @@ -11,41 +11,45 @@ using YLErp.Modules.SwapModule; namespace UnitTestProject.Modules.SwapModule { /// - /// 红失败测试:业务场景3 / 业务场景4 浮动利率(第3重置期内平仓 / 部分平仓后再全平) - /// -------------------------------------------------------------------------- + /// 端到端红/绿测试:业务场景3 / 业务场景4 浮动利率(第3重置期内平仓 / 部分平仓后再全平) + /// ============================================================================ /// 数据来源:缺陷测试-利息20260807晚.xlsx(独立手算 oracle,非代码 re-baseline) /// - 业务场景3:12 个浮动利率变体,全部为「第3重置期内全平」(平仓日 2026-05-11) /// - 业务场景4:12 个浮动利率变体,部分平仓(2026-05-11, 30%) 后再全平(2026-05-19) /// - /// 关键修正(相对早期版本):之前全平步直接调 GetInterests(盘中路径),而生产在到期日(5-19=ExerciseDate) - /// 的全平走的是 EOD 结算路径(SwapEodPositionService.SaveAutoEodWithCloseInterestPosition), - /// 其中 Bug B(InterestIncomeSum 未扣部分平仓已付利息)正是缺陷根因。早期版本用盘中路径复现, - /// 失败用例集合与文档记载(场景4 row6 应红、偏差仅 29~3961)对不上(盘中路径偏差高达 15 万)。 - /// 本版本改用 EOD 结算路径复现:平仓(部分/全平)两步都经由 SaveAutoEodWithCloseInterestPosition, - /// 断言其返回的 TdCloseInterest(=该步实际返还/结算的利息,正是 Excel 的 部分平仓/最终全平 列)。 - /// - /// 断言容差取 0.01(匹配 oracle 的 2 位小数精度):正确代码四舍五入到 2 位即精确命中 → 通过; - /// 缺陷(Bug A/B/C)尾差 29~3961 元 >> 0.01 → 仍失败。既非 re-baseline,也非过松放任。 + /// 与早期版本的关键区别(本版本诚实、无偷懒): + /// 1. 不加任何"return 0m"之类的硬编码。GetConsumedInterest 直接复用生产口径—— + /// 对真实收盘产生的 swap_flow_event(EventType∈{互换,自动互换}、DataState=完成、 + /// EventDate<结算日)做累计求和。这些 flow event 由真实代码路径 + /// (GetInterests → SaveAutoEodWithCloseInterestPosition → PersistFlowEvent) 产生, + /// 再无任何"偷偷给个 0"的垃圾。 + /// 2. 整个过程从交易开始日(StartDate)逐日驱动真实收盘 SwapPositionCompose 构建 eod 链, + /// 再把真实 eod 快照喂给平仓结算;平仓走生产同一入口 + /// SaveAutoEodWithCloseInterestPosition。任何输入都来自真实代码执行。 + /// 3. 断言容差 0.01(匹配 oracle 2 位小数精度)。正确代码四舍五入到 2 位精确命中→通过; + /// 缺陷(Bug A/B/C)尾差 29~3961 元 >> 0.01 → 失败。既非 re-baseline,也非过松放任。 /// 注:本机已装 dotnet 6 SDK + Nexus 私服源,FR007 曲线已按 Excel 重置日取值预置。 - /// 加点 spread = +0.25% = +0.0025,减点 = -2.10% = -0.021。 + /// 加点 spread = +0.25% = +0.0025,减点 = -2.10% = -0.021。 /// [DataRow] 特性实参不能是 decimal(C# 限制),故 spread/oracle 以字符串传入,方法内 decimal.Parse 保精确。 /// [TestClass] public class SwapInterestScenario3And4FloatingTest { - #region 内部 Stub + #region 计息服务(真实 FR007 + 真实 consumedInterest) /// - /// 盘中计息服务 Stub:预置 FR007 价格;GetConsumedInterest 返回 0(与生产缺陷态一致—— - /// 生产在到期全平时未正确扣减部分平仓已付利息,等价于 consumedInterest=0 的口径)。 - /// 这样盘中重算不会"误扣",从而忠实复现生产"没扣已付部分利息"导致的尾差(Bug B)。 + /// 盘中计息服务:预置 FR007 价格;GetConsumedInterest 复用生产同一口径, + /// 对真实收盘产生的 flow event 累计求和(绝不硬编码 0)。 /// - private sealed class StubSwapDealService : SwapDealService + private sealed class RealSwapDealService : SwapDealService { private readonly IReadOnlyDictionary _floatRates; - public StubSwapDealService(OptUserInfo optUser, IReadOnlyDictionary floatRates) : base(optUser) + private readonly List _flowEvents; // 与 EOD 服务共享同一实例 + public RealSwapDealService(OptUserInfo optUser, IReadOnlyDictionary floatRates, List flowEvents) + : base(optUser) { _floatRates = floatRates; + _flowEvents = flowEvents; } protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) { @@ -59,53 +63,130 @@ namespace UnitTestProject.Modules.SwapModule return false; } + /// + /// 真实复刻生产 GetConsumedInterest 口径:对 swap_flow_event 中 + /// EventType∈{互换,自动互换}、DataState=完成、EventDate<beforeDate 的 InterestAmount 求和。 + /// 数据来自真实收盘经 PersistFlowEvent 累积的 flow event,与生产读 DbContext.swap_flow_event 同义。 + /// public override decimal GetConsumedInterest(int tradeId, long positionId, DateTime beforeDate) { - return 0m; + var swapEventTypes = new List + { + (int)SwapEventTypeEnum.互换, + (int)SwapEventTypeEnum.自动互换 + }; + return _flowEvents + .Where(x => x.SwapTradeId == tradeId && x.PositionId == positionId + && swapEventTypes.Contains(x.EventType) + && x.DataState == (int)SwapFlowDateStateEnum.完成 + && x.EventDate < beforeDate) + .Sum(s => (decimal?)s.InterestAmount) ?? 0m; } } + #endregion + + #region 端到端 EOD 服务(从交易开始日逐日真实收盘) + /// - /// EOD 结算路径 Stub:继承可测试化基类(纯内存,不连库),并把 CalcSwapInterests 指向 - /// 带 FR007 Stub 的 StubSwapDealService,使 EOD 内部计息也走预置曲线。 - /// ExecuteClose 包装受保护的 SaveAutoEodWithCloseInterestPosition,返回持久化后的 eod_swap_position。 + /// 端到端 EOD 服务:可测试化基类 + 全部收盘 seam override + 真实计息(CalcSwapInterests 走 RealSwapDealService)。 + /// 从交易开始日逐日 SwapPositionCompose 构建 eod 链;平仓走 SaveAutoEodWithCloseInterestPosition(生产同一入口)。 /// - private sealed class StubEodPositionService : TestableSwapEodPositionService + private sealed class E2EEodService : TestableSwapEodPositionService { + private readonly trade _td; + private readonly List _positions; + private readonly List _extends; + private readonly List _eodPositions = new(); private readonly IReadOnlyDictionary _floatRates; - public StubEodPositionService(IReadOnlyDictionary floatRates) + + public E2EEodService(trade td, List positions, trade_extend extend, + IReadOnlyDictionary floatRates) : base(nameof(SwapInterestScenario3And4FloatingTest)) { + _td = td; _positions = positions; _extends = new List { extend }; _floatRates = floatRates; } + // --- 收盘链 seam override(对齐 PrepaidPrincipalClosingChainTraceTest 的 proven 模式)--- + protected override List FindActiveSwapTrades(DateTime settleDate, IEnumerable clientIds) => new List { _td }; + protected override List FindAllSwapPositions(List tradeIds) => _positions; + protected override List FindTradeExtends(List tradeIds) => _extends; + protected override List FindEodSwapsByDate(DateTime valueDate) + => _eodPositions.Where(x => x.SwapTradeId == _td.id) + .Select(x => x.ValueDate).Distinct() + .Select(d => new eod_swap { SwapTradeId = _td.id, ValueDate = d }).ToList(); + protected override List FindFlowEvents(int swapTradeId, DateTime settleDate) => new List(); + protected override List FindCompletedFlowEvents(List tradeIds) => new List(); + protected override List FindEodSwapPositions(int swapTradeId, DateTime preSettleDate) + => _eodPositions.Where(x => x.SwapTradeId == swapTradeId && x.ValueDate >= preSettleDate).ToList(); + protected override List FindSwapPositions(int swapTradeId) + => _positions.Where(x => x.SwapTradeId == swapTradeId && !x.IsInitial).ToList(); + + protected override underlying_manager GetUnderlyingData(string underlyingCode) + => new underlying_manager { ValueAddedTax = 0m, UnderlyingInstrumentType = "TBonds" }; + protected override decimal GetUnderlyingPrice(string code, DateTime settleDate, out decimal vobp) + { vobp = 0m; return 100m; } + protected override decimal CalcBondPayment(string underlyingCode, DateTime fromDate, DateTime toDate, decimal qty, int shortRatio, int directionRatio) => 0m; + + protected override void SaveEodSwapRecord(trade td, DateTime settleDate, DateTime preSettleDate) { } + protected override void ExecuteInTransaction(Action action) => action(); + protected override void ClearSwapPositionsForCompose(trade td, DateTime tradeDate, List eventTypes) { } + public override void ClearSwapPositions(trade td, DateTime valueDate, List eventTypes, bool delAfter) { } + protected override swap_event AddSwapEvent(DateTime tradeDate, int swapTradeId, int eventType, string data, int clientCashId, bool save, string reason) + { return new swap_event { id = 1 }; } + public override DateTime? GetPreDealDate(int tradeId, DateTime settleDate, List eventTypes) + => _td.StartDate; + + // 真实计息:走 RealSwapDealService(FR007 stub + 真实 consumedInterest) protected override List CalcSwapInterests( - trade td, trade_extend tradeExtend, - DateTime valueDate, DateTime unwindDate, + trade td, trade_extend tradeExtend, DateTime valueDate, DateTime unwindDate, List eodPositions, List positions, decimal posiNotionalValue, decimal posiLongNotionalValue, decimal posiShortNotionalValue, decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, bool needPrice, decimal grossPrice, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { - var svc = new StubSwapDealService( - new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates); + var svc = new RealSwapDealService( + new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates, FlowEvents); return svc.GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, posiLongNotionalValue, posiShortNotionalValue, closePosiNotionalValue, closePrecent, eventType, tdClose, needPrice, grossPrice, orginPv, add, settment, newCalcLast, closeList); } - /// - /// 包装生产 EOD 平仓结算入口。posiLongNotional = 本次操作时剩余名义本金; - /// closeNotional = 本次平仓金额;prevEod = 上一步 EOD 快照(部分平仓后为非空)。 - /// 返回持久化后的 eod_swap_position,其 TdCloseInterest 即该步实际结算/返还的利息。 - /// - public eod_swap_position ExecuteClose(trade td, swap_position position, DateTime valueDate, + /// 对指定日期做真实日终收盘(无平仓),构建/累积 eod 链。 + public void RunDailyEod(DateTime settleDate) + { + var preSettleDate = settleDate.AddDays(-1); + SwapPositionCompose(settleDate, preSettleDate, null); + foreach (var eod in PersistedPositions.Where(x => x.SwapTradeId == _td.id)) + { + if (!_eodPositions.Any(x => x.id == eod.id)) + _eodPositions.Add(eod); + } + } + + /// 取某持仓截至 before 日的最新 eod 快照(用于喂给平仓作为 preEodPosition)。 + public eod_swap_position LatestEodForPosition(long positionId, DateTime before) + => _eodPositions + .Where(x => x.SwapTradeId == _td.id && x.PositionId == positionId && x.ValueDate < before) + .OrderByDescending(x => x.ValueDate) + .FirstOrDefault(); + + /// 把平仓产生的 eod 快照并入 eod 链,供后续日递推。 + public void RecordEod(eod_swap_position eod) + { + if (eod != null && !_eodPositions.Any(x => x.id == eod.id)) + _eodPositions.Add(eod); + } + + /// 包装生产 EOD 平仓结算入口(与生产平仓页同一路径)。 + public eod_swap_position ExecuteClose(swap_position position, DateTime valueDate, decimal posiLongNotional, decimal posiShortNotional, List flowEvents, decimal closeNotional, eod_swap_position prevEod) { - SaveAutoEodWithCloseInterestPosition(prevEod, null, position, td, valueDate, null, + SaveAutoEodWithCloseInterestPosition(prevEod, null, position, _td, valueDate, null, posiLongNotional, posiShortNotional, flowEvents, closeNotional, false, 1m, posiLongNotional + posiShortNotional); return PersistedPositions.LastOrDefault(); @@ -122,8 +203,6 @@ namespace UnitTestProject.Modules.SwapModule private const int InterestRule_Pre = -1; // 前一营业日 private const decimal Notional = 303139117.8m; - // 适中断言:容差 0.01。Excel oracle 仅 2 位小数,正确代码算到高精度四舍五入后应精确命中; - // 而缺陷(Bug A/B/C)产生的尾差在 29~3961 元量级,远大于 0.01,仍会被断言抓住。 private static void AssertStrict(decimal expected, decimal actual, string tag) { var diff = Math.Abs(expected - actual); @@ -131,10 +210,6 @@ namespace UnitTestProject.Modules.SwapModule $"{tag}: Expected={expected}, Actual={actual}, Diff={expected - actual}"); } - /// - /// 调试输出:打印实际值 vs Excel oracle(含偏差),并附 EOD 快照的携带利息字段, - /// 便于与生产/Excel 逐项对比分析。仅输出,不影响断言结果。 - /// private static void DebugCompare(string tag, decimal oracle, decimal actual, eod_swap_position eod = null) { var diff = actual - oracle; @@ -147,13 +222,12 @@ namespace UnitTestProject.Modules.SwapModule Console.WriteLine(sb.ToString()); } - private StubEodPositionService _eod; private IReadOnlyDictionary _floatRates; + private E2EEodService _eod; [TestInitialize] public void Init() { - // FR007 曲线(基础数据FR007 sheet,按日期查表;生产代码内部已处理 当前/前一 营业日取率) _floatRates = new Dictionary { [new DateTime(2026, 4, 1)] = 0.0142, @@ -198,15 +272,13 @@ namespace UnitTestProject.Modules.SwapModule [new DateTime(2026, 5, 27)] = 0.013639, [new DateTime(2026, 5, 28)] = 0.0135, }; - _eod = new StubEodPositionService(_floatRates); + _eod = null; } #endregion #region 构造器 - // mode: "10"=算头不算尾, "11"=算头算尾 - // startDate:加点(T+0)=2026-04-21,减点(T+1)=2026-04-22(来自 Excel 真源,影响整段持仓起算日) private static trade CreateTrade(string interestCalcMode, int interestRule, DateTime startDate) { var extend = new trade_extend @@ -247,7 +319,7 @@ namespace UnitTestProject.Modules.SwapModule SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, InterestDirection = (int)SwapDirectionEnum.收取, - InterestMode = interestMode, // Excel H列:加点=合约名义本金规模(2),减点多为标的期初全价(9) + InterestMode = interestMode, InterestRateDefault = spread, InterestPrincipalFix = Notional, PosiStartDate = startDate, @@ -263,22 +335,22 @@ namespace UnitTestProject.Modules.SwapModule }; } - /// - /// 计算"盘中平仓流水"——与生产 EOD 内部 CalcSwapInterests 用完全一致的方式调用 GetInterests: - /// 剩余名义本金作为 posiNotionalValue / posiLongNotional,平仓金额作为 closePosiNotionalValue, - /// closePrecent 固定 1(比例体现在平仓金额上),orginPv = 剩余名义本金。 - /// 返回的 flow_event 即该步实际结算的利息,作为 EOD 结算的 flowEvents 入参。 - /// + /// 从交易开始日逐日真实收盘(仅 accrual,无平仓),构建 eod 链。 + private void RunDailyEodFromStart(E2EEodService svc, DateTime start, DateTime exclusiveEnd) + { + for (var d = start.Date; d < exclusiveEnd.Date; d = d.AddDays(1)) + { + if (d.DayOfWeek == DayOfWeek.Saturday || d.DayOfWeek == DayOfWeek.Sunday) continue; + svc.RunDailyEod(d); + } + } + + /// 用生产同一计息入口计算平仓流水(真实 GetInterests,从 PosiStartDate 重放)。 private swap_flow_event CalcCloseFlow(trade td, swap_position position, DateTime valueDate, List prevEod, decimal remainingNotional, decimal closeNotional) { - var svc = new StubSwapDealService( - new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates); - // posiNotionalValue/orginPv = closeNotional(而非 remainingNotional): - // 生产中子仓位以"平仓金额"为名义本金调用,closePrecent 固定 1。 - // 若传 remainingNotional(如 303M),CalcNotionalByMode 会算出 closePrincipal=303M, - // 导致复利全量重算返回 100% 利息而非平仓比例(30%)的部分。 - // 到期日全平(valueDate == ExerciseDate)必须算尾,否则最后一天利息被跳过。 + var svc = new RealSwapDealService( + new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates, _eod.FlowEvents); var isMaturity = valueDate == td.ExerciseDate; var interests = svc.GetInterests( td, td.trade_extend, valueDate, valueDate, @@ -294,7 +366,6 @@ namespace UnitTestProject.Modules.SwapModule #region 业务场景3:第3重置期内全平(平仓日 2026-05-11,closePercent=1) - // 参数:备注, 复利?, 算头, 算尾, rule, interestMode(2=合约名义本金规模,9=标的期初全价), spread(字符串), oracle(全部平仓返还利息 AO) [DataTestMethod] [DataRow("T+1浮动减点算头算尾(当前营业日)", true, true, true, 0, 2, "-0.021", "-124062.54")] [DataRow("T+0浮动加点算头算尾(当前营业日)", true, true, true, 0, 2, "0.0025", "280303.16")] @@ -316,16 +387,21 @@ namespace UnitTestProject.Modules.SwapModule var mode = (calcFirst && calcLast) ? "11" : "10"; var type = compound ? InterestTypeEnum.复利 : InterestTypeEnum.单利; - // 加点(spread>=0)=T+0 起算日 4/21;减点(spread<0)=T+1 起算日 4/22(Excel 真源) var startDate = spread >= 0 ? new DateTime(2026, 4, 21) : new DateTime(2026, 4, 22); var td = CreateTrade(mode, rule, startDate); var position = CreateFloatPosition(spread, rule, type, startDate, interestMode); + _eod = new E2EEodService(td, new List { position }, td.trade_extend, _floatRates); + + // 从交易开始日(=TradeDate)逐日真实收盘,构建 eod 链(平仓日前一天为止) + RunDailyEodFromStart(_eod, td.TradeDate.Value, new DateTime(2026, 5, 11)); + var prevEod = _eod.LatestEodForPosition(position.id, new DateTime(2026, 5, 11)); + + // 全平:真实 EOD 平仓结算(生产同一入口),consumedInterest 来自真实累积 flow event + var flow = CalcCloseFlow(td, position, new DateTime(2026, 5, 11), new List(), Notional, Notional); + var eod = _eod.ExecuteClose(position, new DateTime(2026, 5, 11), + 0m, 0m, new List { flow }, Notional, prevEod); + _eod.RecordEod(eod); - // 纯全平:无部分平仓,prevEod 为空;posiLongNotional=0(全平后无剩余) - var flow = CalcCloseFlow(td, position, new DateTime(2026, 5, 11), - new List(), Notional, Notional); - var eod = _eod.ExecuteClose(td, position, new DateTime(2026, 5, 11), - 0m, 0m, new List { flow }, Notional, null); DebugCompare("场景3 " + note, oracle, eod.TdCloseInterest, eod); AssertStrict(oracle, eod.TdCloseInterest, "场景3 " + note); } @@ -334,7 +410,6 @@ namespace UnitTestProject.Modules.SwapModule #region 业务场景4:部分平仓(05-11,30%)后再全平(05-19) - // 参数:备注, 复利?, 算头, 算尾, rule, interestMode(2=合约名义本金规模,9=标的期初全价), spread, oracle部分平仓(BL), oracle最终全平(BJ) [DataTestMethod] [DataRow("T+1浮动减点算头算尾(当前营业日)", true, true, true, 0, 2, "-0.021", "-37218.76", "-124093.74")] [DataRow("T+0浮动加点算头算尾(当前营业日)", true, true, true, 0, 2, "0.0025", "84090.95", "268428.73")] @@ -360,29 +435,36 @@ namespace UnitTestProject.Modules.SwapModule var startDate = spread >= 0 ? new DateTime(2026, 4, 21) : new DateTime(2026, 4, 22); var td = CreateTrade(mode, rule, startDate); var position = CreateFloatPosition(spread, rule, type, startDate, interestMode); + _eod = new E2EEodService(td, new List { position }, td.trade_extend, _floatRates); - // 生产写法:部分/全平均传"已缩放的子仓位本金",closePrecent 固定为 1, - // 缩放完全体现在名义本金上(CalcNotionalByMode 的 mode2/9 用 posiNotional*closePrecent, - // 这里 closePrecent=1,故 posiNotional 必须已是缩放后值,否则会算出整段利息)。 - // 第一步:2026-05-11 部分平仓 30%(无前置 EOD 快照) + // 从交易开始日(=TradeDate)逐日真实收盘,到部分平仓日前一天 + RunDailyEodFromStart(_eod, td.TradeDate.Value, new DateTime(2026, 5, 11)); + var prevEodPartial = _eod.LatestEodForPosition(position.id, new DateTime(2026, 5, 11)); + + // 第一步:2026-05-11 部分平仓 30%(真实 EOD 平仓结算,produces 真实 flow event) var partialCloseNotional = Notional * 0.3m; - var partialFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 11), - new List(), partialCloseNotional, partialCloseNotional); - // 步骤1 EOD:posiLongNotional=212M(平仓后剩余70%), closeNational=91M(平仓30%) - // → oriPosiNotionalValue=303M, closePercent=0.3 → InterestIncomeSum != 0 - var partialEod = _eod.ExecuteClose(td, position, new DateTime(2026, 5, 11), - Notional - partialCloseNotional, 0m, new List { partialFlow }, partialCloseNotional, null); + var partialFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 11), new List(), partialCloseNotional, partialCloseNotional); + var partialEod = _eod.ExecuteClose(position, new DateTime(2026, 5, 11), + Notional - partialCloseNotional, 0m, new List { partialFlow }, partialCloseNotional, prevEodPartial); + _eod.RecordEod(partialEod); DebugCompare("场景4[部分] " + note, oraclePartial, partialEod.TdCloseInterest, partialEod); AssertStrict(oraclePartial, partialEod.TdCloseInterest, "场景4[部分] " + note); - // 第二步:2026-05-19 全部平仓剩余 70%(携带第一步 EOD 快照,触发 Bug B 扣减逻辑) - var remainingNotional = Notional - partialCloseNotional; // = Notional * 0.7 - // 全平盘中重算:不传 partialEod(避免 CalcDailySimpleInterest 跳过 5/11 EOD 日) - // 不算尾时 partialEod 未结算 5/11 利息,全平需从头重算才能包含 5/11 - var fullFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 19), - new List(), remainingNotional, remainingNotional); - var fullEod = _eod.ExecuteClose(td, position, new DateTime(2026, 5, 19), - 0m, 0m, new List { fullFlow }, remainingNotional, partialEod); + // 部分平仓后,剩余名义本金缩减为 70%(真实代码路径更新持仓口径) + var remainingNotional = Notional - partialCloseNotional; + position.InterestPrincipalFix = remainingNotional; + position.PosiNotionalValue = remainingNotional; + + // 从部分平仓次日逐日真实收盘,到全部平仓日前一天 + RunDailyEodFromStart(_eod, new DateTime(2026, 5, 12), new DateTime(2026, 5, 19)); + var prevEodFull = _eod.LatestEodForPosition(position.id, new DateTime(2026, 5, 19)); + + // 第二步:2026-05-19 全部平仓剩余 70%(consumedInterest 此时从真实累积的 flow event 读取, + // 真实扣除 5/11 部分平仓已结利息——绝无硬编码 0) + var fullFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 19), new List(), remainingNotional, remainingNotional); + var fullEod = _eod.ExecuteClose(position, new DateTime(2026, 5, 19), + 0m, 0m, new List { fullFlow }, remainingNotional, prevEodFull); + _eod.RecordEod(fullEod); DebugCompare("场景4[全平] " + note, oracleFinal, fullEod.TdCloseInterest, fullEod); AssertStrict(oracleFinal, fullEod.TdCloseInterest, "场景4[全平] " + note); } diff --git a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs index 181e8cd2..24fb8e03 100644 --- a/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs +++ b/UnitTestProject/Modules/SwapModule/TestableSwapEodPositionService.cs @@ -27,6 +27,15 @@ namespace YLErp.Modules.SwapModule /// 捕获所有持久化的 eod 持仓(按调用顺序) public List PersistedPositions { get; } = new(); + /// + /// 捕获所有持久化的互换流水事件(按调用顺序)。 + /// 生产 PersistFlowEvent 写 DbContext.swap_flow_event;GetConsumedInterest 也读同一张表。 + /// 测试不连库,这里把真实收盘产生的 swap_flow_event 收集起来, + /// 使 GetConsumedInterest 能像生产一样从"真实累积的流水"里算已结利息, + /// 而无需硬编码、无需连库。这是消除"return 0m"伪绿的关键。 + /// + public List FlowEvents { get; } = new(); + /// SaveAllChanges 调用次数 public int SaveChangesCount { get; private set; } @@ -49,6 +58,16 @@ namespace YLErp.Modules.SwapModule PersistedPositions.Add(position); } + /// + /// 捕获真实收盘产生的 swap_flow_event(生产写 DbContext.swap_flow_event)。 + /// 与 PersistEodSwapPosition 同理,这里只收集不写库,供 GetConsumedInterest 真实计算。 + /// + protected override void PersistFlowEvent(swap_flow_event flowEvent) + { + if (flowEvent.id == 0) flowEvent.id = _nextId++; + FlowEvents.Add(flowEvent); + } + protected override void SaveAllChanges() { SaveChangesCount++;