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