From 85746d7ee9e2b0b821845e2ca5753e76e62add9e Mon Sep 17 00:00:00 2001 From: hjhan Date: Wed, 12 Aug 2026 11:28:40 +0800 Subject: [PATCH] =?UTF-8?q?test(accrual):=20=E6=96=B0=E5=A2=9E=E5=90=8C?= =?UTF-8?q?=E6=97=A5=E5=8F=8C=E6=AC=A1=E9=83=A8=E5=88=86=E5=B9=B3=E4=BB=93?= =?UTF-8?q?=E5=AD=97=E7=AC=A6=E5=8C=96=E6=B5=8B=E8=AF=95=20+=20EOD?= =?UTF-8?q?=E5=9F=BA=E6=95=B0=E5=BF=AB=E7=85=A7replay=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SwapUnwindSameDayDoublePartialTest: 内存Stub驱动同日两次部分平仓(30%/50%), 验证基数线性累加(0.3N+0.35N=0.65N),推翻"基数不滚动"假设;缺口仅在 调用方是否传陈旧notional,已文档化 - GLMS20260805ClosePercentDiffDiagnoseTest: 从Resources快照反序列化离线重跑 EOD基数诊断,去DB化;无快照Inconclusive,保留Assert.Fail作8/5 bug护栏 --- ...LMS20260805ClosePercentDiffDiagnoseTest.cs | 91 +++++++++++ .../SwapUnwindSameDayDoublePartialTest.cs | 152 ++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs index 9bb7ea3e..8d34bc48 100644 --- a/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs +++ b/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs @@ -96,6 +96,97 @@ namespace YLErp.Modules.SwapModule #endregion + #region 1.5) 离线回放:从已录快照重跑 EOD 基数诊断(不连库) + + /// + /// 去 DB 化回放:从 落盘的 snapshot_*.json 反序列化 + /// eod_swap_position / swap_position,离线重跑「EOD 预付金基数是否=初始本金」诊断。 + /// + /// 目的:原 Diagnose_100vs40_InterestDiff 直接连 96 库跑 GetUnwindInterests, + /// 依赖数据库可用性、且每次重跑都重新查库。本方法把「一次录制、内存多次回放」 + /// 落地——录制一次(连库)后,后续诊断完全在内存完成,确定性、可重复、不依赖库。 + /// + /// 语义保持为 bug 护栏:若快照录制时 EOD 基数用了初始本金而非实时剩余,本测试 + /// 仍会 Assert.Fail(不掩盖生产 bug)。录制一份「修复后」的快照即可转绿。 + /// 无快照时 Inconclusive(须先连库跑一次 Record_RealSnapshot)。 + /// + [TestMethod] + [TestCategory("DbDiagnose")] + public void Replay_100vs40_FromSnapshot() + { + var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "DbDiagnose", "GLMS20260805"); + if (!Directory.Exists(dir)) + { + Assert.Inconclusive($"未找到快照目录 {dir},请先连库跑一次 Record_RealSnapshot 录制真实数据快照"); + return; + } + var files = Directory.GetFiles(dir, "snapshot_*.json").OrderByDescending(f => f).ToArray(); + if (files.Length == 0) + { + Assert.Inconclusive($"目录 {dir} 下无 snapshot_*.json,请先连库跑一次 Record_RealSnapshot"); + return; + } + + var snapshotPath = files[0]; + Console.WriteLine($"✅ 载入快照(离线回放): {snapshotPath}"); + + var snapshot = JObject.Parse(File.ReadAllText(snapshotPath)); + var tradeNumber = snapshot.Value("TradeNumber"); + Console.WriteLine($"===== 离线回放 交易 {tradeNumber} ====="); + + var eodPrepay = JsonConvert.DeserializeObject>(snapshot["EodPositions"].ToString()) + .Where(e => !e.Invalid + && (e.InterestMode == (int)InterestModeEnum.初始预付金 + || e.InterestMode == (int)InterestModeEnum.追加预付金)) + .OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId).ToList(); + + var origPrepay = JsonConvert.DeserializeObject>(snapshot["Positions"].ToString()) + .Where(p => !p.Invalid && p.IsInitial + && (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)).ToList(); + var realPrepay = JsonConvert.DeserializeObject>(snapshot["Positions"].ToString()) + .Where(p => !p.Invalid && !p.IsInitial + && (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)).ToList(); + + bool bugDetected = false; + if (eodPrepay.Count == 0) + { + Console.WriteLine(" (快照无预付金腿 EOD 记录 → 无可诊断的基数 bug)"); + } + else + { + Console.WriteLine($" {"ValueDate",-12}{"PosId",-8}{"Mode",-6}{"TdInterestPrincipal",-20}{"InterestProfitSum",-20}"); + foreach (var e in eodPrepay) + { + Console.WriteLine($" {e.ValueDate:yyyy-MM-dd} {e.PositionId,-8}{e.InterestMode,-6}{e.TdInterestPrincipal,-20}{e.InterestProfitSum,-20}"); + } + + Console.WriteLine("\n ---- 预付金本金基数三方对比(离线)----"); + foreach (var orig in origPrepay) + { + var real = realPrepay.FirstOrDefault(r => r.PositionId == orig.id); + var latestEod = eodPrepay.Where(e => e.PositionId == orig.id).OrderByDescending(e => e.ValueDate).FirstOrDefault(); + var realFix = real?.InterestPrincipalFix ?? 0; + var eodTd = latestEod?.TdInterestPrincipal ?? 0; + Console.WriteLine($" PosId={orig.id} origFix(初始)={orig.InterestPrincipalFix} realFix(剩余)={realFix} EOD.TdInterestPrincipal(最新)={eodTd}"); + + bool eodMatchesOrig = Math.Abs((double)(eodTd - orig.InterestPrincipalFix)) < 0.01; + bool eodMatchesReal = Math.Abs((double)(eodTd - realFix)) < 0.01; + if (eodMatchesOrig && !eodMatchesReal && orig.InterestPrincipalFix != realFix) + { + bugDetected = true; + Console.WriteLine($" ⚠⚠ EOD 基数=初始本金(≠剩余)→ 坐实:日终用了初始预付金本金而非实时剩余,后续利息计算基数错误!"); + } + } + } + + // bug 护栏:快照若录制到基数 bug,离线回放仍须红,不掩盖生产事故。 + // 修复生产并重新录制快照后,此断言自然转绿。 + Assert.IsFalse(bugDetected, + "离线回放复现 8/5 基数 bug:EOD 预付金基数用了初始本金而非实时剩余。需先修复生产、再录制新快照让本测试转绿。"); + } + + #endregion + #region 2) 诊断:100% vs 40% 利息差异根因定位(连库跑) [TestMethod] diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs new file mode 100644 index 00000000..e6442d6c --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindSameDayDoublePartialTest.cs @@ -0,0 +1,152 @@ +using Newtonsoft.Json; +using YLErp.DBModels; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 【同日多次部分平仓 · unwind 基数滚动表征测试】 + /// ============================================================================ + /// 背景:unwind 计息基数公式 basis = priorNotional + notional - baseNotional + /// (FundingLegAccrual / CalcDailyCompoundInterestByEod 同源),其中 + /// - priorNotional = 上一日终归档 eod_swap_position.TdInterestPrincipal + /// - baseNotional = orginPv = ResolveUnwindPreviousNotional(lastEod)(上一日终浮动端名义本金) + /// - notional = 当前持仓名义本金(posiNotionalValue,来自实时持仓) + /// 既有测试(AS_* / SwapUnwindPrepay*Tdd)全是「单事件」场景,没有覆盖 + /// 「同一天第 2 次部分平仓」:第 1 次平仓后持仓已缩减,第 2 次平仓传入的 + /// notional 应是缩减后的实时值。本文件用内存对象驱动真实 GetInterests 两次, + /// 定性验证「同日多次部分平仓」的应返还本金/计息基数是否按线性拆分。 + /// + /// 建模:标的期初全价腿(mode=9),初始名义本金 N=1,000,000;上一日终归档 + /// eod.TdInterestPrincipal=N、PosiNotionalValue=N(lastEod)。 + /// 第1次平仓 30%(closePercent=0.3,传入 notional=N) + /// 第2次平仓剩余 50%(closePercent=0.5,传入 notional=0.7N=实时缩减后) + /// 预期(领域线性):IP1=0.3N、IP2=0.5×0.7N=0.35N,合计 0.65N。 + /// 若公式在 notional 正确传入时仍非线性 → 暴露 unwind 基数滚动缺陷。 + /// 注:本测试同时是「前置条件护栏」——它证明"只要调用方传入实时缩减后的 + /// notional,公式即线性正确";若生产在第2次平仓时传入的是未缩减的陈旧 notional, + /// 则结果会偏离,需另查调用方(GetUnwindInterests 的 notional 来源)。 + /// ============================================================================ + /// + [TestClass] + public class SwapUnwindSameDayDoublePartialTest + { + private sealed class StubSwapDealService : SwapDealService + { + public StubSwapDealService(OptUserInfo optUser) : base(optUser) { } + + protected override bool TryGetFloatRate(DateTime valueDate, string underlyingCode, out double rate) + { + rate = 0; + return false; // 标的期初全价腿无浮动标的,不查库 + } + } + + private const decimal N = 1_000_000m; // 初始名义本金(标的期初全价维度) + private const int AnnualDays = 365; + private static readonly DateTime StartDate = new(2026, 8, 1); + private static readonly DateTime LastEodDate = new(2026, 8, 4); + private static readonly DateTime UnwindDate = new(2026, 8, 5); + + private SwapDealService _svc; + + [TestInitialize] + public void Init() => _svc = new StubSwapDealService(new OptUserInfo(0, nameof(SwapUnwindSameDayDoublePartialTest), OptUserFrom.UnitTest)); + + private static trade MakeTrade() + { + var extend = new trade_extend + { + TradeId = 1, + ExtendJson = JsonConvert.SerializeObject(new TradeExtendJson + { + AnnualDays = AnnualDays, + InterestCalcMode = "10", // 算头不算尾 + SettlementRules = 0 + }) + }; + return new trade + { + id = 1, TradeNumber = "UT-SAMEDAY-2UNWIND", ClientId = 999997, + TradeType = "收益互换", TradeDate = StartDate, StartDate = StartDate, + ExerciseDate = new DateTime(2027, 8, 1), TradeStatus = "确认成交", ValidState = "Valid", + StockEqvNotional = (double)N, Notional = (double)N, + trade_extend = extend + }; + } + + /// 标的期初全价腿(mode=9),单利、重置周期1天(无重置日分支,隔离基数滚动行为)。 + private static swap_position MakePosition(decimal posiNotionalValue) + { + return new swap_position + { + id = 1001, SwapTradeId = 1, PositionType = (int)PositionTypeFlag.Unknown, + InterestDirection = (int)SwapDirectionEnum.收取, + InterestMode = (int)InterestModeEnum.标的期初全价, + InterestRateDefault = 0.01m, InterestPrincipalFix = 0m, + PosiStartDate = StartDate, PosiMatuirityDate = new DateTime(2027, 8, 1), + IsInitial = true, Invalid = false, InterestType = (int)InterestTypeEnum.单利, + IsAnnualized = true, interest_rest_days = 1, + interest_rule = 0, FloatRateUnderlyingCode = null, + InterestSwapInterval = "[]", + PosiNotionalValue = posiNotionalValue + }; + } + + /// 上一日终归档:basis 锚点。TdInterestPrincipal=N、PosiNotionalValue=N(lastEod 尚未缩减)。 + private static List MakeLastEod() + { + return new List + { + new eod_swap_position + { + id = 1, SwapTradeId = 1, PositionId = 1001, + ValueDate = LastEodDate, + TdInterestPrincipal = N, + PosiNotionalValue = N, + InterestProfitSum = 0m, FloatRate = 0m + } + }; + } + + /// + /// 驱动一次盘中平仓(与前端平仓页相同路径,仅用内存对象、不查库)。 + /// = 本次平仓时实时持仓名义本金; + /// = 占剩余比例(前端 ToRemainingClosePercent 转换后的值)。 + /// orginPv 取 lastEod 名义本金 N(与 GetUnwindInterests 真实传参 ResolveUnwindPreviousNotional(lastEod) 一致)。 + /// + private swap_flow_event CalcUnwind(decimal currentNotional, decimal closePercent) + { + var td = MakeTrade(); + var position = MakePosition(currentNotional); + var interests = _svc.GetInterests(td, td.trade_extend, UnwindDate, UnwindDate, + MakeLastEod(), new List { position }, + currentNotional, currentNotional, currentNotional, currentNotional * closePercent, closePercent, + (int)SwapEventTypeEnum.平仓, + false, false, 0, N, false, settment: false, newCalcLast: false, closeList: null); + Assert.AreEqual(1, interests.Count, "标的期初全价腿应生成 1 条 flow_event"); + return interests[0]; + } + + [TestMethod] + public void 同日两次部分平仓_应返还本金应线性拆分且合计等于65pct() + { + // 第1次:平仓 30%(持仓仍满 N) + var fe1 = CalcUnwind(N, 0.3m); + // 第2次:同日再平剩余 50%(持仓已缩减为 0.7N,传入实时 notional) + var fe2 = CalcUnwind(0.7m * N, 0.5m); + + Console.WriteLine($"[表征] 第1次(30%) InterestPrincipal={fe1.InterestPrincipal} InterestAmount={fe1.InterestAmount}"); + Console.WriteLine($"[表征] 第2次(剩余50%) InterestPrincipal={fe2.InterestPrincipal} InterestAmount={fe2.InterestAmount}"); + Console.WriteLine($"[表征] 合计 InterestPrincipal={fe1.InterestPrincipal + fe2.InterestPrincipal} (期望=0.65N={(0.65m * N)})"); + + // 领域预期(线性):第1次返 0.3N,第2次返 0.5×0.7N=0.35N,合计 0.65N + Assert.AreEqual(0.3m * N, fe1.InterestPrincipal, + "第1次平仓30%: 应返还本金应=0.3N(线性)"); + Assert.AreEqual(0.35m * N, fe2.InterestPrincipal, + "第2次平仓剩余50%: 应返还本金应=0.5×0.7N=0.35N(基于实时缩减后的 notional,线性)"); + Assert.AreEqual(0.65m * N, fe1.InterestPrincipal + fe2.InterestPrincipal, + "同日两次部分平仓合计应返还本金应=0.65N(线性拆分,无重复/遗漏)"); + } + } +}