From d51dd8a60bf26ef5d153e57710cda41b41ff527f Mon Sep 17 00:00:00 2001 From: hjhan Date: Thu, 16 Jul 2026 11:09:12 +0800 Subject: [PATCH] =?UTF-8?q?test(swap):=20=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=AF=B9=E9=BD=90=20GLMS-20260701-0008=20=E5=9B=9B?= =?UTF-8?q?=E6=AC=A1=E9=83=A8=E5=88=86=E5=B9=B3=E4=BB=93=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产 deal 又平仓两次(共四次),实时预付金腿 InterestPrincipalFix 由 73,260 降至 66,813.12(4 次返还 9,900+15,840+3,663+2,783.88=32,186.88; 99,000−32,186.88=66,813.12,与 dev 库实时腿完全勾稽)。 - SwapUnwindPrepayOrigVsRealBugTdd:RemainingFix 常量 73,260→66,813.12; 头部注释同步为四次平仓;新增 GLMS20260701_四次部分平仓_LiveSnapshot 用例, 把 4 次平仓真实数据硬编码为忠实回归(日后该 deal 再平仓需同步更新)。 - 纳入此前遗漏提交的连库诊断测试 GLMS20260701DbDiagnoseTest.cs (使用 DbContextFactory 取连接串,无硬编码密码,安全)。 验证:dotnet test --filter SwapUnwindPrepay → 26 passed(19+7)失败 0; YLErpDAL / UnitTestProject 编译通过(仅既有警告)。 --- .../SwapModule/GLMS20260701DbDiagnoseTest.cs | 215 ++++++++++++++++++ .../SwapUnwindPrepayOrigVsRealBugTdd.cs | 36 ++- 2 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs new file mode 100644 index 00000000..3bbea3ad --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GLMS20260701DbDiagnoseTest.cs @@ -0,0 +1,215 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using YLErp.DBModels.Enums; + +namespace YLErp.Modules.SwapModule +{ + /// + /// 线上事故诊断:GLMS-20260701-0008 多次部分平仓后,预付金返还显示仍为原始值 + /// ============================================================================ + /// 直连测试库,录制真实数据快照并定位根因(DB 端还是计算端)。 + /// 测试结构: + /// 1) RecordSnapshot - 录 trade/position/eod_swap_position/eod_swap/flow_event + /// 2) Diagnose - 把每次部分平仓前后 InterestPrincipalFix 实际值序列打印, + /// 验证是否双重扣减;并调用 GetUnwindInterests 1.0 看后端返还值 + /// 3) 期望对比 - 多次部分平仓后,1.0 closePercent 应返"剩余本金"(=已扣减后), + /// 若仍返原始值 ⇒ 后端 EOD 路径 bug (SaveAutoEodWithCloseInterestPosition 双重扣减) + /// + [TestClass] + public class GLMS20260701DbDiagnoseTest + { + private const string TradeNumber = "GLMS-20260701-0008"; + + #region 1) 录真实数据快照(手动跑) + + [TestMethod] + [Ignore] + [TestCategory("DbDiagnose")] + public void Record_RealSnapshot() + { + YLContext db; + try { db = DbContextFactory.GetYLDbContext(); } + catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } + + var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber); + Assert.IsNotNull(td, $"测试库无交易 {TradeNumber},请确认环境"); + + var snapshot = new JObject + { + ["TradeNumber"] = td.TradeNumber, + ["TradeId"] = td.id, + ["StockEqvNotional"] = td.StockEqvNotional, + ["Notional"] = td.Notional, + ["OriginalStockEqvNotional"] = td.OriginalStockEqvNotional, + ["TradeDate"] = td.TradeDate, + ["StartDate"] = td.StartDate, + ["ExerciseDate"] = td.ExerciseDate + }; + + // 1.1 当前所有仓位(含 IsInitial=初始 + !IsInitial=已平后剩余) + var positions = db.swap_position + .Where(p => p.SwapTradeId == td.id && !p.Invalid) + .OrderBy(p => p.IsInitial).ThenBy(p => p.id) + .ToList(); + snapshot["Positions"] = JArray.FromObject(positions, JsonSerializer.Create(new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + DateFormatHandling = DateFormatHandling.IsoDateFormat + })); + + // 1.2 EOD 持仓序列(关键:观察 InterestPrincipalFix 逐日变化) + var eodPositions = db.eod_swap_position + .Where(e => e.SwapTradeId == td.id && !e.Invalid && e.InterestMode == 5 || e.InterestMode == 6) + .OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId) + .ToList(); + snapshot["EodPositions_MarginLegOnly"] = JArray.FromObject(eodPositions, JsonSerializer.Create(new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + DateFormatHandling = DateFormatHandling.IsoDateFormat + })); + + // 1.3 EOD 交易级(eod_swap.NotionalValue 应该是初始值不变) + var eodSwaps = db.eod_swap.Where(e => e.SwapTradeId == td.id).OrderBy(e => e.ValueDate).ToList(); + snapshot["EodSwaps"] = JArray.FromObject(eodSwaps, JsonSerializer.Create(new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + DateFormatHandling = DateFormatHandling.IsoDateFormat + })); + + // 1.4 所有 flow_event(看平仓/互换事件序列,及 InterestPrincipal 实际写入值) + var flows = db.swap_flow_event.Where(f => f.SwapTradeId == td.id).OrderBy(f => f.EventDate).ThenBy(f => f.id).ToList(); + snapshot["FlowEvents"] = JArray.FromObject(flows, JsonSerializer.Create(new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + DateFormatHandling = DateFormatHandling.IsoDateFormat + })); + + var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "DbDiagnose", "GLMS20260701"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, $"snapshot_{DateTime.Now:yyyyMMdd_HHmmss}.json"); + File.WriteAllText(path, JsonConvert.SerializeObject(snapshot, Formatting.Indented, + new JsonSerializerSettings { DateFormatHandling = DateFormatHandling.IsoDateFormat })); + Console.WriteLine($"✅ 快照已保存: {path}"); + } + + #endregion + + #region 2) 诊断:打印"预付金腿"逐日本金变化 + 后端 API 1.0 全平应返值 + + [TestMethod] + [TestCategory("DbDiagnose")] + public void Diagnose_InterestPrincipalFix_Progression_And_UnwindResult() + { + YLContext db; + try { db = DbContextFactory.GetYLDbContext(); } + catch (Exception ex) { Assert.Inconclusive($"无法连接测试库:{ex.Message}"); return; } + + var td = db.trade.FirstOrDefault(t => t.TradeNumber == TradeNumber); + if (td == null) { Assert.Inconclusive($"测试库无 {TradeNumber}"); return; } + + // 2.1 预付金腿 position.InterestPrincipalFix 当前值(多次平仓后应该已被扣减) + var marginPositions = db.swap_position + .Where(p => p.SwapTradeId == td.id && !p.Invalid + && (p.InterestMode == (int)InterestModeEnum.初始预付金 + || p.InterestMode == (int)InterestModeEnum.追加预付金)) + .ToList(); + + Console.WriteLine("============== 预付金腿 position 当前值(多次平仓后) =============="); + foreach (var p in marginPositions) + { + Console.WriteLine($"PositionId={p.id} Mode={p.InterestMode} Fix={p.InterestPrincipalFix} Rate={p.InterestRateDefault} Dir={p.InterestDirection} IsInitial={p.IsInitial}"); + } + + // 2.1b 所有 position 全景(含浮动腿),对比 IsInitial vs !IsInitial 的 PosiNotionalValue / Fix + var allPositions = db.swap_position + .Where(p => p.SwapTradeId == td.id && !p.Invalid) + .OrderBy(p => p.IsInitial).ThenBy(p => p.id) + .ToList(); + + Console.WriteLine("\n============== 全部 position 全景(对比 IsInitial 原始 vs !IsInitial 剩余) =============="); + Console.WriteLine($" {"Id",-8}{"Mode",-6}{"Dir",-6}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}{"UnderlyingCode",-15}"); + foreach (var p in allPositions) + { + var ul = p.UnderlyingCode ?? ""; + Console.WriteLine($" {p.id,-8}{p.InterestMode,-6}{p.InterestDirection,-6}{p.IsInitial,-8}{p.InterestPrincipalFix,-18}{p.PosiNotionalValue,-18}{p.PosiQuantity,-12}{ul,-15}"); + } + + // 2.1c 关键诊断:GetUnwindInterests 内部 origPositions vs realPostitions 差异 + var origPositions = allPositions.Where(x => x.IsInitial).ToList(); + var realPostitions = allPositions.Where(x => !x.IsInitial).ToList(); + Console.WriteLine("\n============== GetUnwindInterests 关键源数据对比 =============="); + Console.WriteLine($" origPositions(IsInitial=True) 浮动腿 PosiNotionalValue 总和: {origPositions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue)}"); + Console.WriteLine($" realPostitions(IsInitial=False) 浮动腿 PosiNotionalValue 总和: {realPostitions.Where(x => x.PosiDirection > 0).Sum(s => s.PosiNotionalValue)} ← 应为剩余值"); + Console.WriteLine($" origPositions(IsInitial=True) 预付金腿 Fix: {string.Join(",", origPositions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))}"); + Console.WriteLine($" realPostitions(IsInitial=False) 预付金腿 Fix: {string.Join(",", realPostitions.Where(x => x.InterestMode == 5 || x.InterestMode == 6).Select(x => x.InterestPrincipalFix))} ← 应为剩余值"); + + // 2.2 EOD 持仓 InterestPrincipalFix 逐日序列 + var eodMarginSeq = db.eod_swap_position + .Where(e => e.SwapTradeId == td.id && !e.Invalid + && (e.InterestMode == (int)InterestModeEnum.初始预付金 + || e.InterestMode == (int)InterestModeEnum.追加预付金)) + .OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId) + .ToList(); + + Console.WriteLine("============== EOD 预付金腿 InterestPrincipalFix 逐日变化 =============="); + foreach (var e in eodMarginSeq) + { + Console.WriteLine($" ValueDate={e.ValueDate:yyyy-MM-dd} PositionId={e.PositionId} Fix={e.InterestPrincipalFix} TdInterestPrincipal={e.TdInterestPrincipal} PosiStatus={e.PosiStatus} Invalid={e.Invalid}"); + } + + // 2.3 平仓事件序列(看 InterestPrincipal 实际入库值) + var closeFlows = db.swap_flow_event + .Where(f => f.SwapTradeId == td.id && f.EventType == (int)SwapEventTypeEnum.平仓 + && f.DataState == (int)SwapFlowDateStateEnum.完成 + && (f.InterestMode == (int)InterestModeEnum.初始预付金 + || f.InterestMode == (int)InterestModeEnum.追加预付金)) + .OrderBy(f => f.EventDate).ToList(); + + Console.WriteLine("============== 历史平仓事件-预付金腿 实际 InterestPrincipal 序列 =============="); + foreach (var f in closeFlows) + { + Console.WriteLine($" EventDate={f.EventDate:yyyy-MM-dd} PositionId={f.PositionId} InterestPrincipal={f.InterestPrincipal} InterestAmount={f.InterestAmount} Quantity={f.Quantity} TradingAmount={f.TradingAmount}"); + } + + // 2.4 直调后端 GetUnwindInterests(closePercent=1.0) 看"按全部平仓应返"的预付金值 + try + { + var user = new OptUserInfo(0, nameof(GLMS20260701DbDiagnoseTest), OptUserFrom.UnitTest); + var svc = new SwapDealService(user); + var interests = svc.GetUnwindInterests(DateTime.Today, DateTime.Today, td.id, 1.0m, (int)SwapEventTypeEnum.平仓); + + Console.WriteLine("============== 后端 GetUnwindInterests(1.0) 实际返回值-预付金腿 =============="); + foreach (var it in interests.Where(i => i.InterestMode == 5 || i.InterestMode == 6)) + { + Console.WriteLine($" PositionId={it.PositionId} Mode={it.InterestMode} InterestPrincipal={it.InterestPrincipal} InterestAmount={it.InterestAmount} InterestRate={it.InterestRate}"); + } + + // 诊断断言:1.0 全平应返 = realPostitions(剩余持仓)的 InterestPrincipalFix + // 后端为保持 eod_swap_position.PositionId 日终归档对齐,返回的 PositionId 仍是 origPositions.id, + // 但 InterestPrincipal 应等于 realPostitions[real.PositionId == orig.id].Fix(剩余值)。 + // 因此对比口径:apiRet.InterestPrincipal vs realLeg.Fix(剩余值),不是 vs origPos.Fix(原始值)。 + Console.WriteLine("============== 修复验证(apiRet.InterestPrincipal vs realLeg.Fix 剩余值)=============="); + int okCount = 0, badCount = 0; + foreach (var origPos in marginPositions.Where(p => p.IsInitial)) + { + var apiRet = interests.FirstOrDefault(i => i.PositionId == origPos.id); + if (apiRet == null) { Console.WriteLine($" ⚠ PositionId={origPos.id} 后端未返回"); continue; } + var realLeg = marginPositions.FirstOrDefault(p => !p.IsInitial && p.PositionId == origPos.id); + decimal expectedFix = realLeg?.InterestPrincipalFix ?? origPos.InterestPrincipalFix; + var diff = Math.Abs((double)(apiRet.InterestPrincipal - expectedFix)); + bool ok = diff < 0.01; + if (ok) okCount++; else badCount++; + Console.WriteLine($" {(ok ? "✓" : "✗")} PositionId={origPos.id}(origFix={origPos.InterestPrincipalFix}) → realLeg.Fix={expectedFix} 后端返={apiRet.InterestPrincipal} 差={diff:F4}"); + } + Console.WriteLine($"\n 结论:通过 {okCount} 条 / 失败 {badCount} 条"); + Assert.IsTrue(badCount == 0, $"修复未生效:{badCount} 条预付金腿后端返还值 ≠ realLeg.Fix 剩余值"); + } + catch (Exception ex) + { + Console.WriteLine($"⚠ GetUnwindInterests 调用失败:{ex.Message}"); + } + } + + #endregion + } +} diff --git a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayOrigVsRealBugTdd.cs b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayOrigVsRealBugTdd.cs index 77fb0841..e74b1274 100644 --- a/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayOrigVsRealBugTdd.cs +++ b/UnitTestProject/Modules/SwapModule/SwapUnwindPrepayOrigVsRealBugTdd.cs @@ -8,11 +8,12 @@ namespace YLErp.Modules.SwapModule /// --------------------------------------------------------------- /// 生产铁证 GLMS-20260701-0008(SwapTradeId=1993,dev DB 192.168.2.96 / glms_yltrs_ylcms): /// 预付金腿(InterestMode=5) 双轨记录—— - /// orig 35798 (IsInitial=1, PosiDirection=0, InterestPrincipalFix=99,000) ← 期初腿,恒为初始值 - /// real 35871 (IsInitial=0, PositionId=35798, InterestPrincipalFix=73,260) ← 实时腿,已扣减 2 次平仓(9,900+15,840) + /// orig 35798 (IsInitial=1, PosiDirection=0, InterestPrincipalFix=99,000) ← 期初腿,恒为初始值 + /// real 35871 (IsInitial=0, PositionId=35798, InterestPrincipalFix=66,813.12) ← 实时腿,已扣减 4 次平仓 + /// (9,900 + 15,840 + 3,663 + 2,783.88 = 32,186.88;99,000 − 32,186.88 = 66,813.12,与 dev 库实时腿完全勾稽) /// /// 根因:GetUnwindInterests 的利息腿迭代源取 origPositions(IsInitial=1),其预付金腿 - /// InterestPrincipalFix 恒=99,000;而"当前剩余本金"73,260 存在 real 腿。GetInterests 算 + /// InterestPrincipalFix 恒=99,000;而"当前剩余本金"66,813.12 存在 real 腿。GetInterests 算 /// closePrincipal = Fix × closePercent 与预付金计息基数 orginPv 都读 position.InterestPrincipalFix, /// 于是多次部分平仓后打开平仓页,"返回预付金"仍按初始 99,000 计算——完全不对。 /// 首次平仓时 orig==real,掩盖了该 bug(解释"为何只修好一次部分平仓")。 @@ -33,7 +34,7 @@ namespace YLErp.Modules.SwapModule private const long OrigId = 35798; private const long RealId = 35871; private const decimal InitialFix = 99_000m; // orig 腿初始本金 - private const decimal RemainingFix = 73_260m; // real 腿剩余本金(已扣减 2 次平仓) + private const decimal RemainingFix = 66_813.12m; // real 腿剩余本金(已扣减 4 次平仓 9,900+15,840+3,663+2,783.88=32,186.88) private static swap_position OrigPrepay(decimal fix = InitialFix, int mode = (int)InterestModeEnum.初始预付金) => new swap_position @@ -70,7 +71,7 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(1, result.Count, "应保留 1 条利息腿"); Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix, - "多次部分平仓后:预付金腿本金应=实时腿剩余本金 73,260,而非原始腿初始值 99,000(bug 症状)"); + "多次部分平仓后:预付金腿本金应=实时腿剩余本金 66,813.12,而非原始腿初始值 99,000(bug 症状)"); // 必须是 Clone,不能污染原始腿(原始腿要保留 99,000 供其他路径/审计) Assert.AreEqual(InitialFix, origs[0].InterestPrincipalFix, "修复必须走 Clone,绝不能就地改写 origPositions 的初始本金"); @@ -148,5 +149,30 @@ namespace YLErp.Modules.SwapModule Assert.AreEqual(OrigId, result[0].id, "保留的应是预付金利息腿"); Assert.AreEqual(RemainingFix, result[0].InterestPrincipalFix, "且其本金已对齐实时剩余本金"); } + + /// + /// 生产 Live Snapshot(2026-07-16 11:00,dev DB 192.168.2.96 / glms_yltrs_ylcms 直连核实): + /// GLMS-20260701-0008 已 4 次部分平仓。预付金腿(orig 35798 / real 35871) 实际值—— + /// orig InterestPrincipalFix = 99,000(期初腿恒为初始值) + /// real InterestPrincipalFix = 66,813.12(= 99,000 − 9,900 − 15,840 − 3,663 − 2,783.88) + /// swap_flow_event 4 次平仓返还:9,900 / 15,840 / 3,663 / 2,783.88,合计 32,186.88。 + /// 本用例把这份真实数据硬编码进来,断言修复后取实时腿剩余本金 66,813.12(非 99,000), + /// 作为该 deal 在此快照点的忠实回归;日后该 deal 再被平仓,剩余本金会变,本例仍应同步更新。 + /// + [TestMethod] + public void GLMS20260701_四次部分平仓_LiveSnapshot_预付金腿应取实时腿剩余66813_12() + { + // 与生产一致的双轨数据:期初腿 99,000 / 实时腿 4 次平仓后 66,813.12 + var origs = new List { OrigPrepay(InitialFix) }; + var reals = new List { RealPrepay(fix: 66_813.12m) }; + + var result = SwapDealService.ResolveInterestLegPositions(origs, reals); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(66_813.12m, result[0].InterestPrincipalFix, + "4 次部分平仓后:预付金腿本金应=实时腿剩余本金 66,813.12,而非原始腿初始值 99,000"); + // 不污染原始腿 + Assert.AreEqual(InitialFix, origs[0].InterestPrincipalFix, "修复必须走 Clone,不能改写 origPositions 的初始本金 99,000"); + } } }