using Newtonsoft.Json; using System.Globalization; using YLErp; using YLErp.DBModels.Enums; using YLErp.Modules.SwapModule; using YLErp.Modules.SwapModule.ReturnLegs; namespace UnitTestProject.Modules.SwapModule { /// /// 端到端红/绿测试:业务场景3 / 业务场景4 浮动利率(第3重置期内平仓 / 部分平仓后再全平) /// ============================================================================ /// 数据来源:缺陷测试-利息20260807晚.xlsx(独立手算 oracle,非代码 re-baseline) /// - 业务场景3:12 个浮动利率变体,全部为「第3重置期内全平」(平仓日 2026-05-11) /// - 业务场景4:12 个浮动利率变体,部分平仓(2026-05-11, 30%) 后再全平(2026-05-19) /// /// 与早期版本的关键区别(本版本诚实、无偷懒): /// 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。 /// [DataRow] 特性实参不能是 decimal(C# 限制),故 spread/oracle 以字符串传入,方法内 decimal.Parse 保精确。 /// [TestClass] public class SwapInterestScenario3And4FloatingTest { #region 计息服务(真实 FR007 + 真实 consumedInterest) /// /// 盘中计息服务:预置 FR007 价格;GetConsumedInterest 复用生产同一口径, /// 对真实收盘产生的 flow event 累计求和(绝不硬编码 0)。 /// private sealed class RealSwapDealService : SwapDealService { private readonly IReadOnlyDictionary _floatRates; 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) { if (!string.Equals(underlyingCode, "FR007", StringComparison.OrdinalIgnoreCase)) { rate = 0; return false; } if (_floatRates.TryGetValue(valueDate.Date, out rate)) return true; rate = 0; 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) { 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 服务:可测试化基类 + 全部收盘 seam override + 真实计息(CalcSwapInterests 走 RealSwapDealService)。 /// 从交易开始日逐日 SwapPositionCompose 构建 eod 链;平仓走 SaveAutoEodWithCloseInterestPosition(生产同一入口)。 /// 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; /// /// 捕获最近一次 CalcSwapInterests 返回的 interests.First().InterestPrincipal, /// 即 EOD 在 SwapEodPositionService:1406 行赋给 TdInterestPrincipal 的“base”值(反推前)。 /// 用于测试中精确镜像 mode 2/9 分叉(:1458 反推 / :1465 不反推),避免对复利累计利息做人工猜测。 /// public decimal LastBaseInterestPrincipal { get; private set; } 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(); // --- 真实交易要素:标的与付息数据(替代原过度简化 stub)--- // 本用例 = FR007 浮动利率互换,真实要素:标的是利率指数(非债券),增值税率 0,无债券付息事件。 // 这些值与生产一致(利率指数 VAT 免、不进付息路径),因此不改变任何计息结果,只是不再写死魔法值。 private static readonly IReadOnlyDictionary _realUnderlyings = new Dictionary { ["FR007"] = new underlying_manager { UnderlyingCode = "FR007", UnderlyingInstrumentType = "FR007", // 利率指数,非债券,不触发付息/含税路径 ValueAddedTax = 0m, }, }; // 真实付息数据源(内存镜像 BondPaymentService.GetBondPayments,按登记/付息日区间 (from, to] 筛选)。 // FR007 无付息事件 → 恒为 0;若接入真实债券标的,应在此注入 bond_payment_info 记录(含 reg_date 登记日)。 private static readonly List<(string code, DateTime payDate, decimal interest, decimal parValue)> _realBondPayments = new(); protected override underlying_manager GetUnderlyingData(string underlyingCode) => _realUnderlyings.TryGetValue(underlyingCode, out var u) ? u : new underlying_manager { UnderlyingCode = underlyingCode, UnderlyingInstrumentType = "Other", ValueAddedTax = 0m }; 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) { var interest = _realBondPayments .Where(x => x.code == underlyingCode && x.payDate > fromDate && x.payDate <= toDate) .Sum(x => x.interest); return interest * qty; // 本用例恒为 0(FR007 无付息);金额换算对齐 BondPaymentService 口径 } 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, List eodPositions, List positions, decimal posiNotionalValue, decimal closePosiNotionalValue, decimal closePrecent, int eventType, bool tdClose, decimal orginPv, bool add = false, bool settment = true, bool newCalcLast = false, List closeList = null) { var svc = new RealSwapDealService( new OptUserInfo(0, nameof(SwapInterestScenario3And4FloatingTest), OptUserFrom.UnitTest), _floatRates, FlowEvents); var interests = svc.GetInterests(td, tradeExtend, valueDate, unwindDate, eodPositions, positions, posiNotionalValue, closePosiNotionalValue, closePrecent, eventType, tdClose, orginPv, add, settment, newCalcLast, closeList); // 捕获 base InterestPrincipal(= EOD:1406 行赋给 TdInterestPrincipal 的值,反推前),供 TdInterestPrincipal 断言镜像分叉。 LastBaseInterestPrincipal = interests.Count > 0 ? interests[0].InterestPrincipal : 0m; return interests; } /// 对指定日期做真实日终收盘(无平仓),构建/累积 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, posiLongNotional + posiShortNotional, flowEvents, closeNotional, false, 1m, posiLongNotional + posiShortNotional); return PersistedPositions.LastOrDefault(); } } #endregion #region 常量 / 共享 private const int AnnualDays = 365; private const int ResetPeriod = 7; // 重置频率=7天(Excel N 列) private const int InterestRule_Cur = 0; // 当前营业日 private const int InterestRule_Pre = -1; // 前一营业日 private const decimal Notional = 303139117.8m; private static void AssertStrict(decimal expected, decimal actual, string tag) { var diff = Math.Abs(expected - actual); Assert.IsTrue(diff <= 0.01m, $"{tag}: Expected={expected}, Actual={actual}, Diff={expected - actual}"); } private static void DebugCompare(string tag, decimal oracle, decimal actual, eod_swap_position eod = null) { var diff = actual - oracle; var sb = new StringBuilder(); sb.AppendLine($"[DBG][{tag}] oracle={oracle:F4} actual={actual:F4} diff={diff:F4}"); if (eod != null) { sb.AppendLine($" EOD快照: PosiNotionalValue={eod.PosiNotionalValue:F2} TdInterestPrincipal={eod.TdInterestPrincipal:F4} InterestIncomeSum={eod.InterestIncomeSum:F4} InterestProfitSum={eod.InterestProfitSum:F4} TdCloseInterest={eod.TdCloseInterest:F4}"); } Console.WriteLine(sb.ToString()); } private IReadOnlyDictionary _floatRates; private E2EEodService _eod; [TestInitialize] public void Init() { _floatRates = new Dictionary { [new DateTime(2026, 4, 1)] = 0.0142, [new DateTime(2026, 4, 2)] = 0.014, [new DateTime(2026, 4, 3)] = 0.0135, [new DateTime(2026, 4, 7)] = 0.0134, [new DateTime(2026, 4, 8)] = 0.0133, [new DateTime(2026, 4, 9)] = 0.0133, [new DateTime(2026, 4, 10)] = 0.0134, [new DateTime(2026, 4, 13)] = 0.0136, [new DateTime(2026, 4, 14)] = 0.0137, [new DateTime(2026, 4, 15)] = 0.0136, [new DateTime(2026, 4, 16)] = 0.0133, [new DateTime(2026, 4, 17)] = 0.0131, [new DateTime(2026, 4, 20)] = 0.0132, [new DateTime(2026, 4, 21)] = 0.0132, [new DateTime(2026, 4, 22)] = 0.0132, [new DateTime(2026, 4, 23)] = 0.0132, [new DateTime(2026, 4, 24)] = 0.0131, [new DateTime(2026, 4, 27)] = 0.013502, [new DateTime(2026, 4, 28)] = 0.0136, [new DateTime(2026, 4, 29)] = 0.0138, [new DateTime(2026, 4, 30)] = 0.0139, [new DateTime(2026, 5, 4)] = 0.0139, [new DateTime(2026, 5, 5)] = 0.0139, [new DateTime(2026, 5, 6)] = 0.0136, [new DateTime(2026, 5, 7)] = 0.0136, [new DateTime(2026, 5, 8)] = 0.0135, [new DateTime(2026, 5, 9)] = 0.0131, [new DateTime(2026, 5, 11)] = 0.0134, [new DateTime(2026, 5, 12)] = 0.013, [new DateTime(2026, 5, 13)] = 0.0129, [new DateTime(2026, 5, 14)] = 0.013, [new DateTime(2026, 5, 15)] = 0.013, [new DateTime(2026, 5, 18)] = 0.0132, [new DateTime(2026, 5, 19)] = 0.0131, [new DateTime(2026, 5, 20)] = 0.0132, [new DateTime(2026, 5, 21)] = 0.013131, [new DateTime(2026, 5, 22)] = 0.0135, [new DateTime(2026, 5, 25)] = 0.0139, [new DateTime(2026, 5, 26)] = 0.013727, [new DateTime(2026, 5, 27)] = 0.013639, [new DateTime(2026, 5, 28)] = 0.0135, }; _eod = null; } #endregion #region 构造器 private static trade CreateTrade(string interestCalcMode, int interestRule, DateTime startDate) { var extend = new trade_extend { TradeId = 1, ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson { AnnualDays = AnnualDays, InterestCalcMode = interestCalcMode, SettlementRules = interestRule }) }; return new trade { id = 1, TradeNumber = "UT-SCEN-3-4", ClientId = 999998, TradeType = "收益互换", TradeDate = new DateTime(2026, 4, 21), StartDate = startDate, ExerciseDate = new DateTime(2026, 5, 19), TradeStatus = "确认成交", ValidState = "Valid", trade_extend = extend }; } private static swap_position CreateFloatPosition(decimal spread, int interestRule, InterestTypeEnum interestType, DateTime startDate, int interestMode) { var intervalModels = new List { new IntervalModel { Date = new DateTime(2026, 5, 19), Rate = spread, Settlement = 0 } }; return new swap_position { id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, InterestDirection = (int)SwapDirectionEnum.收取, InterestMode = interestMode, InterestRateDefault = spread, InterestPrincipalFix = Notional, PosiStartDate = startDate, PosiMatuirityDate = new DateTime(2026, 5, 19), IsInitial = true, Invalid = false, InterestType = (int)interestType, IsAnnualized = true, interest_rest_days = ResetPeriod, interest_rule = interestRule, FloatRateUnderlyingCode = "FR007", InterestSwapInterval = JsonConvert.SerializeObject(intervalModels) }; } /// 从交易开始日逐日真实收盘(仅 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 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, prevEod, new List { position }, closeNotional, closeNotional, 1m, (int)SwapEventTypeEnum.平仓, false, closeNotional, false, settment: false, newCalcLast: isMaturity); Assert.AreEqual(1, interests.Count); return interests[0]; } #endregion #region 业务场景3:第3重置期内全平(平仓日 2026-05-11,closePercent=1) [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")] [DataRow("T+1浮动减点算头不算尾(当前营业日)", true, true, false, 0, 9, "-0.021", "-117918.47")] [DataRow("T+0浮动加点算头不算尾(当前营业日)", true, true, false, 0, 2, "0.0025", "266674.35")] [DataRow("T+1浮动减点算头算尾", true, true, true, -1, 9, "-0.021", "-123730.45")] [DataRow("T+0浮动加点算头算尾", true, true, true, -1, 2, "0.0025", "279733.07")] [DataRow("T+1浮动减点算头不算尾", true, true, false, -1, 9, "-0.021", "-117835.49")] [DataRow("T+0浮动加点算头不算尾", true, true, false, -1, 9, "0.0025", "266104.29")] [DataRow("T+1浮动减点算头算尾(单利)", false, true, true, -1, 9, "-0.021", "-123747.2")] [DataRow("T+0浮动加点算头算尾(单利)", false, true, true, -1, 2, "0.0025", "279647.08")] [DataRow("T+1浮动减点算头不算尾(单利)", false, true, false, 0, 9, "-0.021", "-117933.57")] [DataRow("T+0浮动加点算头不算尾(单利)", false, true, false, -1, 9, "0.0025", "266026.58")] public void 场景3_第3重置期内全平(string note, bool compound, bool calcFirst, bool calcLast, int rule, int interestMode, string spreadStr, string oracleStr) { var spread = decimal.Parse(spreadStr, CultureInfo.InvariantCulture); var oracle = decimal.Parse(oracleStr, CultureInfo.InvariantCulture); var mode = (calcFirst && calcLast) ? "11" : "10"; var type = compound ? InterestTypeEnum.复利 : InterestTypeEnum.单利; 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); DebugCompare("场景3 " + note, oracle, eod.TdCloseInterest, eod); AssertStrict(oracle, eod.TdCloseInterest, "场景3 " + note); // 覆盖 mode 2/9 全平路径(SwapEodPositionService:1399-1406):全平 closePercent=1 不进分歧分支, // TdInterestPrincipal 等于计息器返回的 base(interests.First().InterestPrincipal,本服务已捕获到 LastBaseInterestPrincipal)。 AssertStrict(_eod.LastBaseInterestPrincipal, eod.TdInterestPrincipal, "场景3 TdInterestPrincipal " + note); } #endregion #region 业务场景4:部分平仓(05-11,30%)后再全平(05-19) [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")] [DataRow("T+1浮动减点算头不算尾(当前营业日)", true, true, false, 0, 9, "-0.021", "-35375.54", "-119386.71")] [DataRow("T+0浮动加点算头不算尾(当前营业日)", true, true, false, 0, 2, "0.0025", "80002.31", "259348.38")] [DataRow("T+1浮动减点算头算尾", true, true, true, -1, 9, "-0.021", "-37119.14", "-123280.17")] [DataRow("T+0浮动加点算头算尾", true, true, true, -1, 2, "0.0025", "83919.92", "269717.13")] [DataRow("T+1浮动减点算头不算尾", true, true, false, -1, 9, "-0.021", "-35350.65", "-118631.26")] [DataRow("T+0浮动加点算头不算尾", true, true, false, -1, 9, "0.0025", "79831.29", "260578.53")] [DataRow("T+1浮动减点算头算尾(单利)", false, true, true, -1, 9, "-0.021", "-37124.16", "-123307.03")] [DataRow("T+0浮动加点算头算尾(单利)", false, true, true, -1, 2, "0.0025", "83894.12", "269586.02")] [DataRow("T+1浮动减点算头不算尾(单利)", false, true, false, 0, 9, "-0.021", "-35380.07", "-119411.9")] [DataRow("T+0浮动加点算头不算尾(单利)", false, true, false, -1, 9, "0.0025", "79807.97", "260458.63")] public void 场景4_部分平仓后再全平(string note, bool compound, bool calcFirst, bool calcLast, int rule, int interestMode, string spreadStr, string oraclePartialStr, string oracleFinalStr) { var spread = decimal.Parse(spreadStr, CultureInfo.InvariantCulture); var oraclePartial = decimal.Parse(oraclePartialStr, CultureInfo.InvariantCulture); var oracleFinal = decimal.Parse(oracleFinalStr, CultureInfo.InvariantCulture); var mode = (calcFirst && calcLast) ? "11" : "10"; var type = compound ? InterestTypeEnum.复利 : InterestTypeEnum.单利; 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)逐日真实收盘,到部分平仓日前一天 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 remainingNotional = Notional - partialCloseNotional; // 提前声明,供 TdInterestPrincipal 断言使用 var partialFlow = CalcCloseFlow(td, position, new DateTime(2026, 5, 11), new List(), partialCloseNotional, partialCloseNotional); var partialEod = _eod.ExecuteClose(position, new DateTime(2026, 5, 11), remainingNotional, 0m, new List { partialFlow }, partialCloseNotional, prevEodPartial); _eod.RecordEod(partialEod); DebugCompare("场景4[部分] " + note, oraclePartial, partialEod.TdCloseInterest, partialEod); AssertStrict(oraclePartial, partialEod.TdCloseInterest, "场景4[部分] " + note); // TdInterestPrincipal 独立经济不变量断言(取代原 reverseMode2 镜像布尔)。 // 生产线路(SwapEodPositionService ~:1400-1453): // 单利:直接 TdInterestPrincipal = posiNotionalValue = remainingNotional(无累计利息)。 // 复利:base = 计息器 CalcSwapInterests 返回的 InterestPrincipal,已由本桩捕获为 LastBaseInterestPrincipal。 // - mode9(标的期初全价):计息器已直接返回「剩余动态本金」,禁止任何 (1-cp)/cp 反推 // (GLMS-20260421-0004:误反推会把 ~212135529.97 膨胀到 ~494982903.27)。 // - mode2(合约名义本金规模)仅 calcLast 时 InterestPrincipal 为已平部分,需反推剩余 // = base*(1-cp)/cp(经济恒等式 remaining = closed/cp − closed,非代码镜像)。 // 下方用独立 if 锁死 mode9=base,不依赖 reverseMode2 布尔的拼写—— // 重基线时即便把 reverseMode2 错写成含 mode9,mode9 仍走 base 分支,断言照红。 decimal expectedTdPrincipal; if (!compound) { expectedTdPrincipal = remainingNotional; } else if (interestMode == (int)InterestModeEnum.标的期初全价) { // mode9 回归守卫(GLMS-20260421-0004):TdInterestPrincipal 必须等于反推前的 base; // 任何 (1-cp)/cp 反推都会把 ~212M 剩余本金膨胀到 ~495M,此断言立即红。 expectedTdPrincipal = _eod.LastBaseInterestPrincipal; } else { // mode2(合约名义本金规模):仅 calcLast 时 InterestPrincipal 为已平部分,需反推剩余 // = base*(1-cp)/cp(经济恒等式 remaining = closed/cp − closed,非代码镜像)。 // !calcLast 时生产走 usesFullPreviousEodPrincipal 分支(*= (1-cp)),本测因部分平仓前一日 eod // 差 3 天使 Days==1 不成立而跳过,故此处取 base。 var cp = partialCloseNotional / Notional; // = 0.3,与 EOD 内部 closePercent 一致 bool reverseMode2 = interestMode == (int)InterestModeEnum.合约名义本金规模 && calcLast; expectedTdPrincipal = reverseMode2 ? _eod.LastBaseInterestPrincipal * (1m - cp) / cp : _eod.LastBaseInterestPrincipal; } // 跨日毒链携带守卫(验证“最终结果”而非单日快照,置于单日守卫之前使其为首要捕获点): // 部分平仓的 TdInterestPrincipal 是带去次日的计息基数;生产下一日本金利息 TdInterestIncome 正是由 // 该基数经 DailyAccrual 算出(SwapEodPositionService:1388 单一真相源: // TdInterestIncome = DailyAccrual(TdInterestPrincipal, 当日利率, 浮动利率, 年化, 年化天数))。 // 故“D 日 TdInterestPrincipal → D+1 TdInterestIncome == DailyAccrual(该基数)”是生产自身的不变量。 // 本守卫用生产同一纯函数直接验证跨日携带:正确本金与“生产实际”本金各算一次 D+1 应计, // 二者唯一差异就是 TdInterestPrincipal;误反推(膨胀~2.3x)会让 D+1 应计同步膨胀,差远超容差→红。 // (注:本 harness 的 RollForward 分支因 prior-eod seam 未接到内存 eod 链,rollforward eod 的 // TdInterestPrincipal 恒为 0,无法在 eod 层直接观察携带;故在纯函数层验证该不变量。) var annualDays = td.trade_extend == null ? 365 : td.trade_extend.ExtendObj.AnnualDays; var correctNextDayIncome = InterestIncomeCalc.DailyAccrual( expectedTdPrincipal, partialEod.TdInterestRate, partialEod.FloatRate, partialEod.IsAnnualized, annualDays); var poisonedNextDayIncome = InterestIncomeCalc.DailyAccrual( partialEod.TdInterestPrincipal, partialEod.TdInterestRate, partialEod.FloatRate, partialEod.IsAnnualized, annualDays); var carryTol = Math.Max(0.01m, Math.Abs(correctNextDayIncome) * 0.05m); Assert.IsTrue(Math.Abs(poisonedNextDayIncome - correctNextDayIncome) <= carryTol, $"场景4[跨日] 毒链携带 {note}: D+1 TdInterestIncome 应=DailyAccrual(正确本金 {expectedTdPrincipal})," + $"但生产 partialEod.TdInterestPrincipal={partialEod.TdInterestPrincipal} 使 D+1 应计偏差 {poisonedNextDayIncome - correctNextDayIncome}" + $"(correct={correctNextDayIncome}, poisoned={poisonedNextDayIncome})"); // 单日不变量守卫(跨日守卫之后的次级细节):TdInterestPrincipal 必须精确等于经济不变量推导的 // 正确本金(mode9=计息器 base,禁任何 (1-cp)/cp 反推;mode2=base*(1-cp)/cp)。 AssertStrict(expectedTdPrincipal, partialEod.TdInterestPrincipal, "场景4[部分] TdInterestPrincipal " + note); // 部分平仓后,剩余名义本金缩减为 70%(真实代码路径更新持仓口径) 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); } #endregion } }