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 } }