diff --git a/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs b/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs new file mode 100644 index 00000000..9bb7ea3e --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/GLMS20260805ClosePercentDiffDiagnoseTest.cs @@ -0,0 +1,282 @@ +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() + { + 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 2) 诊断:100% vs 40% 利息差异根因定位(连库跑) + + [TestMethod] + [TestCategory("DbDiagnose")] + public void Diagnose_100vs40_InterestDiff() + { + 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; + + var interests100 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp100_B, (int)SwapEventTypeEnum.平仓); + var interests40 = new SwapDealService(user).GetUnwindInterests(valueDate, unwindDate, td.id, cp40_B, (int)SwapEventTypeEnum.平仓); + + 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 + } +}