using Newtonsoft.Json; using Newtonsoft.Json.Linq; using YLErp.DBModels.Enums; namespace YLErp.Modules.SwapModule { /// /// 线上事故诊断:GLMS-JIATT-20260805-FICC-01-2180120IB 100%平仓 vs 40%平仓 利息差异异常 /// ============================================================================ /// 现象:同一笔交易,100% 平仓与 40% 平仓算出的利息差异远大于线性比例。 /// 怀疑点:8/5 创建的交易,撞上 8/6-8/7 对 SwapDealService 平仓利息计算的密集修复窗口, /// 尤其 3a435ad8(8/7 13:46) 把 ResolveInterestLegPositionsAsOf 分桶从 UnwindDate 改回 /// EventDate、并删除 01d7f0c5 的 priorClosePositionIds 防护,可能引入回归。 /// /// 直连 96 测试库,对这笔交易: /// 1) 录真实数据快照(trade/position/eod/flow_event) /// 2) 分别调 GetUnwindInterests(closePercent=1.0) 和 (=0.4),逐腿打印本金/利息 /// 3) 对比两者是否成线性比例;定位差异落在哪条腿、哪个字段 /// 4) 检查 EOD 快照的预付金 TdInterestPrincipal 是否用了初始本金(坐实 8/5 基数 bug) /// /// 用法:本地连 96 库跑 Diagnose_100vs40_InterestDiff;连不上库自动 Inconclusive 跳过。 /// [TestClass] public class GLMS20260805ClosePercentDiffDiagnoseTest { private const string TradeNumber = "GLMS-JIATT-20260805-FICC-01-2180120IB"; #region 1) 录真实数据快照(手动跑,标 Ignore) [TestMethod] [Ignore] [TestCategory("DbDiagnose")] public void Record_RealSnapshot() { DbDiagnoseGuard.RequireTestDb(); 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},请确认 96 库是否有该数据"); var snapshot = new JObject { ["TradeNumber"] = td.TradeNumber, ["TradeId"] = td.id, ["TradeDate"] = td.TradeDate, ["StartDate"] = td.StartDate, ["StockEqvNotional"] = td.StockEqvNotional, ["OriginalStockEqvNotional"] = td.OriginalStockEqvNotional, ["Notional"] = td.Notional, ["OriginalNotional"] = td.OriginalNotional, ["TradeStatus"] = td.TradeStatus, ["HasPartialUnWind"] = td.HasPartialUnWind }; // 持仓(含 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 })); // EOD 预付金腿逐日(关键:看 TdInterestPrincipal 是否=初始本金) var eodPositions = db.eod_swap_position .Where(e => e.SwapTradeId == td.id && !e.Invalid) .OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId) .ToList(); snapshot["EodPositions"] = JArray.FromObject(eodPositions, JsonSerializer.Create(new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore, DateFormatHandling = DateFormatHandling.IsoDateFormat })); // 所有 flow_event(看平仓事件序列、EventDate vs UnwindDate) 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", "GLMS20260805"); 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 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] [TestCategory("DbDiagnose")] public void Diagnose_100vs40_InterestDiff() { DbDiagnoseGuard.RequireTestDb(); 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; } Console.WriteLine($"===== 交易 {TradeNumber} (id={td.id}) ====="); Console.WriteLine($" TradeDate={td.TradeDate:yyyy-MM-dd} StartDate={td.StartDate:yyyy-MM-dd}"); Console.WriteLine($" StockEqvNotional(剩余)={td.StockEqvNotional} Original(期初)={td.OriginalStockEqvNotional}"); string remainRatio = td.OriginalStockEqvNotional == 0 ? "N/A" : (td.StockEqvNotional / td.OriginalStockEqvNotional.Value).ToString("P2"); Console.WriteLine($" 剩余比例={remainRatio}"); Console.WriteLine($" HasPartialUnWind={td.HasPartialUnWind} TradeStatus={td.TradeStatus}"); Console.WriteLine(); // ---- A. 持仓全景 ---- var allPositions = db.swap_position .Where(p => p.SwapTradeId == td.id && !p.Invalid) .OrderBy(p => p.IsInitial).ThenBy(p => p.id) .ToList(); PrintPositions("持仓全景(orig=IsInitial初始 vs real=!IsInitial剩余)", allPositions); // ---- B. 历史平仓事件(确认是否之前平过仓、EventDate vs UnwindDate 是否一致)---- PrintCloseFlowEvents(db, td.id); // ---- C. EOD 预付金腿逐日(看基数是否=初始本金 → 坐实 8/5 基数 bug)---- PrintEodPrepaySequence(db, td.id); // ---- D. 核心对比:分别调 100% 和 40% ---- Console.WriteLine("\n\n############ 核心:100% vs 40% GetUnwindInterests 对比 ############"); var user = new OptUserInfo(0, nameof(GLMS20260805ClosePercentDiffDiagnoseTest), OptUserFrom.UnitTest); // 前端传"占期初(A)"语义,后端转"占剩余(B)"。这里模拟前端两种选择。 decimal frontNotionalValue = Convert.ToDecimal(td.OriginalStockEqvNotional ?? 0d); // 期初 decimal frontPosiNotionalValue = Convert.ToDecimal(td.StockEqvNotional); // 剩余 Console.WriteLine($"\n 前端参数:期初={frontNotionalValue} 剩余={frontPosiNotionalValue}"); // 选 100%(占期初 A=1.0) decimal cp100_A = 1.0m; decimal cp100_B = SwapDealService.ToRemainingClosePercent(cp100_A, frontNotionalValue, frontPosiNotionalValue); Console.WriteLine($" [100%] 前端A={cp100_A} → 后端B={cp100_B}(占剩余)"); // 选 40%(占期初 A=0.4) decimal cp40_A = 0.4m; decimal cp40_B = SwapDealService.ToRemainingClosePercent(cp40_A, frontNotionalValue, frontPosiNotionalValue); Console.WriteLine($" [40%] 前端A={cp40_A} → 后端B={cp40_B}(占剩余)"); Console.WriteLine($" 注:若期初≠剩余,A=1.0→B 被 cap 到 1,A=0.4→B 是另一值,二者本就非线性。\n"); // 计算日期用今天(实际前端选哪天可改) var valueDate = DateTime.Today; var unwindDate = DateTime.Today; // FR007 fixing 是外部数据依赖:非交易日/数据未发布时取价会抛 Exception。 // 与"连不上库自动 Inconclusive"同语义——外部数据不可用不应判为测试失败。 List interests100, interests40; try { interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.平仓); interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.平仓); } catch (Exception ex) when (ex.Message.Contains("获取不到") && ex.Message.Contains("价格")) { Assert.Inconclusive($"FR007 fixing 数据不可用({valueDate:yyyy-MM-dd} 非交易日或数据未发布):{ex.Message}"); return; } PrintInterestComparison(interests100, interests40, cp100_B, cp40_B); } #endregion #region 打印辅助 private static void PrintPositions(string title, List positions) { Console.WriteLine($"===== {title} ====="); Console.WriteLine($" {"Id",-8}{"Mode",-6}{"IntDir",-7}{"PosiDir",-8}{"IsInit",-8}{"Fix",-18}{"PosiNotional",-18}{"PosiQty",-12}"); foreach (var p in positions) { Console.WriteLine($" {p.id,-8}{p.InterestMode,-6}{p.InterestDirection,-7}{p.PosiDirection,-8}{p.IsInitial,-8}{p.InterestPrincipalFix,-18}{p.PosiNotionalValue,-18}{p.PosiQuantity,-12}"); } } private static void PrintCloseFlowEvents(YLContext db, int tradeId) { Console.WriteLine($"\n===== 历史平仓/互换事件(EventDate vs UnwindDate)====="); var flows = db.swap_flow_event .Where(f => f.SwapTradeId == tradeId && (f.EventType == (int)SwapEventTypeEnum.平仓 || f.EventType == (int)SwapEventTypeEnum.互换 || f.EventType == (int)SwapEventTypeEnum.自动互换) && f.DataState == (int)SwapFlowDateStateEnum.完成) .OrderBy(f => f.EventDate).ThenBy(f => f.id) .ToList(); if (flows.Count == 0) { Console.WriteLine(" (无历史平仓/互换事件 → 此前未平过仓)"); return; } Console.WriteLine($" {"EventDate",-12}{"UnwindDate",-12}{"一致?",-8}{"Type",-6}{"PosId",-8}{"Mode",-6}{"I.Principal",-16}{"I.Amount",-14}"); foreach (var f in flows) { var sameDate = f.EventDate == f.UnwindDate; var typeStr = f.EventType == (int)SwapEventTypeEnum.平仓 ? "平仓" : f.EventType == (int)SwapEventTypeEnum.互换 ? "互换" : "自动"; Console.WriteLine($" {f.EventDate:yyyy-MM-dd} {f.UnwindDate:yyyy-MM-dd} {(sameDate ? "是" : "否⚠"),-6}{typeStr,-6}{f.PositionId,-8}{f.InterestMode,-6}{f.InterestPrincipal,-16}{f.InterestAmount,-14}"); } Console.WriteLine(" ⚠ EventDate≠UnwindDate 的历史事件:当前 3a435ad8 按 EventDate 分桶,可能与 UnwindDate 口径不一致"); } private static void PrintEodPrepaySequence(YLContext db, int tradeId) { Console.WriteLine($"\n===== EOD 预付金腿逐日(看 TdInterestPrincipal 是否=初始本金)====="); var eodPrepay = db.eod_swap_position .Where(e => e.SwapTradeId == tradeId && !e.Invalid && (e.InterestMode == (int)InterestModeEnum.初始预付金 || e.InterestMode == (int)InterestModeEnum.追加预付金)) .OrderBy(e => e.ValueDate).ThenBy(e => e.PositionId) .ToList(); if (eodPrepay.Count == 0) { Console.WriteLine(" (无预付金腿 EOD 记录)"); return; } Console.WriteLine($" {"ValueDate",-12}{"PosId",-8}{"Mode",-6}{"TdInterestPrincipal",-20}{"InterestProfitSum",-20}{"InterestIncomeSum",-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}{e.InterestIncomeSum,-20}"); } // 对比初始 vs 实时剩余 vs EOD var origPrepay = db.swap_position.Where(p => p.SwapTradeId == tradeId && !p.Invalid && p.IsInitial && (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)).ToList(); var realPrepay = db.swap_position.Where(p => p.SwapTradeId == tradeId && !p.Invalid && !p.IsInitial && (p.InterestMode == (int)InterestModeEnum.初始预付金 || p.InterestMode == (int)InterestModeEnum.追加预付金)).ToList(); 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; var eodMatchesOrig = Math.Abs((double)(eodTd - orig.InterestPrincipalFix)) < 0.01; var eodMatchesReal = Math.Abs((double)(eodTd - realFix)) < 0.01; Console.WriteLine($" PosId={orig.id} origFix(初始)={orig.InterestPrincipalFix} realFix(剩余)={realFix} EOD.TdInterestPrincipal(最新)={eodTd}"); if (eodMatchesOrig && !eodMatchesReal && orig.InterestPrincipalFix != realFix) { Console.WriteLine($" ⚠⚠ EOD 基数=初始本金(≠剩余)→ 坐实:日终用了初始预付金本金而非实时剩余,后续利息计算基数错误!"); } } } private static void PrintInterestComparison(List interests100, List interests40, decimal cp100_B, decimal cp40_B) { Console.WriteLine($"\n ---- GetUnwindInterests 返回(100% 共{interests100.Count}条 / 40% 共{interests40.Count}条)----"); Console.WriteLine($" {"PosId",-8}{"Mode",-6}{"IntDir",-8}{"I.Principal(100)",-18}{"I.Principal(40)",-18}{"本金比",-10}{"I.Amount(100)",-16}{"I.Amount(40)",-16}{"利息比",-10}"); decimal totalAmount100 = 0, totalAmount40 = 0; foreach (var i100 in interests100.OrderBy(x => x.PositionId)) { var i40 = interests40.FirstOrDefault(x => x.PositionId == i100.PositionId && x.InterestMode == i100.InterestMode); var amt40 = i40?.InterestAmount ?? 0; var prin40 = i40?.InterestPrincipal ?? 0; totalAmount100 += i100.InterestAmount; totalAmount40 += amt40; string prinRatio = prin40 == 0 ? "-" : (i100.InterestPrincipal / prin40).ToString("F4"); string amtRatio = amt40 == 0 ? "-" : (i100.InterestAmount / amt40).ToString("F4"); Console.WriteLine($" {i100.PositionId,-8}{i100.InterestMode,-6}{i100.InterestDirection,-8}{i100.InterestPrincipal,-18}{prin40,-18}{prinRatio,-10}{i100.InterestAmount,-16}{amt40,-16}{amtRatio,-10}"); } Console.WriteLine($"\n ===== 利息合计 ====="); Console.WriteLine($" 100% 总利息 = {totalAmount100}"); Console.WriteLine($" 40% 总利息 = {totalAmount40}"); var ratioStr = totalAmount40 == 0 ? "N/A" : (totalAmount100 / totalAmount40).ToString("F4"); Console.WriteLine($" 比值(100/40) = {ratioStr}"); Console.WriteLine($" 若为线性关系,比值应≈{cp100_B / cp40_B:F4}(即 B_100 / B_40)"); Console.WriteLine($" 若实际比值远偏离此值 → 存在非线性/bug,重点看上方哪条腿的[利息比]或[本金比]异常"); Console.WriteLine($"\n ===== 诊断结论指引 ====="); Console.WriteLine(" · 本金比≠B_100/B_40:ResolveInterestLegPositions 没用实时剩余本金(看 realFix vs origFix)"); Console.WriteLine(" · 复利腿利息比异常:检查 consumedInterest 扣除(GetConsumedInterest 用 EventDate 过滤)"); Console.WriteLine(" · 单利腿利息比异常:检查 preEodPosition.InterestProfitSum 基数(EOD 是否用了初始本金)"); Console.WriteLine(" · 全部腿都偏:closePercent 双语义转换 + tdClose 导致计息区间坍缩"); } #endregion } }