using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace YLErp.Modules.SwapModule { /// /// 互换分红损益 - 黄金文件录制测试(A1:仅录制,不回放) /// ============================================================================ /// 目的: /// 从测试库录制一笔"带分红的互换交易"完整生命周期数据(5表快照), /// 序列化为 JSON golden 文件。既作为: /// (1) 人工/脚本验证"分红重复计算 2 次"的数据证据; /// (2) 后续 EOD 重构(抽虚方法)后,回放回归测试的 golden source 种子。 /// /// 为什么是录制而不是回放: /// 回放需要 SwapEodPositionService 把 DB 调用抽成虚方法(参考 /// refactor-swap-event-testable 分支的 TestableSwapEodPositionService)。 /// 当前 1.4.2 分支尚未做该重构,故先录制 golden 数据。 /// TDD 红灯:录制数据会暴露 RealizedPnl 中分红被计 2 次的事实, /// 待"方向A:让 MarkClosePnl 不含分红"修复后,同一批 golden 用于回归守底。 /// /// 运行方式: /// 全部标 [Ignore]+[TestCategory("DBRecording")],不会自动跑(不依赖测试库环境)。 /// 手动执行:在测试资源管理器取消忽略,或用 vstest: /// vstest.console.exe UnitTestProject.dll /TestCaseFilter:"TestCategory=DBRecording" /// 录制产物落 bin/$(Configuration)/net6.0/Resources/GoldenFiles/SwapDividend/*.json /// ============================================================================ [TestClass] public class SwapDividendGoldenRecordTest { private static readonly string GoldenDir = Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "Resources", "GoldenFiles", "SwapDividend"); private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings { Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Include, DateFormatString = "yyyy-MM-ddTHH:mm:ss", ReferenceLoopHandling = ReferenceLoopHandling.Ignore }; // 已确认的样本交易(来自测试库 swap_flow_event EventType in(3,4) DividendIn<>0 筛选): // 1875 = 纯分红型(MarkClosePnl==DividendIn,最干净,重复计算最直观) // 1891 = 混合型 (MarkClosePnl 含价差成分,复杂场景) private static readonly int[] SampleTradeIds = { 1875, 1891 }; /// /// Step0:列出库中所有"带分红的互换交易",确认样本有效性。 /// 打印 SwapTradeId / 分红合计 / 盯市合计 / eod快照数,供挑选样本。 /// /// 可直接运行:连不上测试库时返回 Inconclusive(不计入失败),不挡 CI; /// 连得上时输出诊断表。这是日常排查"库里有啥分红交易"的入口。 /// [TestMethod] [TestCategory("DBRecording")] public void Step0_ListDividendSwapTrades() { YLContext db; try { db = DbContextFactory.GetYLDbContext(); } catch (Exception ex) { Assert.Inconclusive($"无法连接测试库(CI/无DB环境正常跳过):{ex.Message}"); return; } try { var trades = db.swap_flow_event .Where(x => (x.EventType == (int)SwapFlowEventTypeEnum.互换 || x.EventType == (int)SwapFlowEventTypeEnum.自动互换) && x.DividendIn != 0m && x.DataState == (int)SwapFlowDateStateEnum.完成) .AsEnumerable() .GroupBy(x => x.SwapTradeId) .Select(g => new { SwapTradeId = g.Key, SwapTradeNo = g.Select(x => x.SwapTradeNo).FirstOrDefault(s => !string.IsNullOrEmpty(s)), DividendSum = g.Sum(x => x.DividendIn), MarkCloseSum = g.Sum(x => x.MarkClosePnl), LastEventDate = g.Max(x => x.EventDate), EodPositionCount = db.eod_swap_position.Count(e => e.SwapTradeId == g.Key), EodSwapCount = db.eod_swap.Count(e => e.SwapTradeId == g.Key) }) .OrderByDescending(t => Math.Abs(t.DividendSum)) .ToList(); Console.WriteLine($"=== 带分红的互换交易数: {trades.Count} ==="); Console.WriteLine($"{"TradeId",8} {"SwapTradeNo",-24} {"分红合计",14} {"盯市合计",14} {"eod持仓",8} {"eod汇总",8}"); foreach (var t in trades) { Console.WriteLine($"{t.SwapTradeId,8} {(t.SwapTradeNo ?? ""),-24} {t.DividendSum,14:F4} {t.MarkCloseSum,14:F4} {t.EodPositionCount,8} {t.EodSwapCount,8}"); // 直观诊断:分红型交易若 MarkCloseSum≈DividendSum,说明 MarkClosePnl 全是分红(重复计算铁证) if (Math.Abs(t.MarkCloseSum - t.DividendSum) < 0.01m && t.DividendSum != 0m) { Console.WriteLine($" ↳ ⚠ MarkClosePnl合计≈DividendIn合计 → 盯市列里全是分红,RealizedPnl 会计 2 次"); } } Assert.IsTrue(trades.Count > 0, "库中应存在带分红的互换交易"); } finally { db?.Dispose(); } } /// /// Step1:逐笔录制样本交易的完整快照(5表),并输出分红重复计算诊断。 /// /// 为何保留 [Ignore]:本方法有写文件副作用(落 golden JSON), /// 不应随每次构建/CI 自动执行;只在需要"刷新 golden 种子"时手动触发。 /// 运行方式(三选一): /// - VS 测试资源管理器:选中本方法 → 右键 → 运行(VS 默认会跑被 Ignore 的,除非全局过滤) /// - 命令行:dotnet test --filter "FullyQualifiedName~Step1_RecordSampleTrades" /// - 临时:删掉本方法上的 [Ignore] 再跑,跑完恢复 /// [TestMethod] [TestCategory("DBRecording")] // [Ignore] 临时取消以运行录制 public void Step1_RecordSampleTrades() { using var db = DbContextFactory.GetYLDbContext(); Directory.CreateDirectory(GoldenDir); int recorded = 0; foreach (var tradeId in SampleTradeIds) { Console.WriteLine($"\n========== 录制 SwapTradeId={tradeId} =========="); // 1. 交易主信息 var trade = db.trade.FirstOrDefault(t => t.id == tradeId); if (trade == null) { Console.WriteLine($"⚠ trade 表无 SwapTradeId={tradeId},跳过"); continue; } // 2. 五表快照 var positions = db.swap_position .Where(p => p.SwapTradeId == tradeId) .OrderByDescending(p => p.IsInitial).ThenBy(p => p.PositionId) .ToList(); var flowEvents = db.swap_flow_event .Where(e => e.SwapTradeId == tradeId) .OrderBy(e => e.EventDate).ThenBy(e => e.EventType).ThenBy(e => e.id) .ToList(); var eodPositions = db.eod_swap_position .Where(e => e.SwapTradeId == tradeId) .OrderBy(e => e.PositionId).ThenBy(e => e.ValueDate) .ToList(); var eodSwaps = db.eod_swap .Where(e => e.SwapTradeId == tradeId) .OrderBy(e => e.ValueDate) .ToList(); // 3. 关联的债券付息(理论应付分红来源) var bondCode = positions.Select(p => p.UnderlyingCode).FirstOrDefault(c => !string.IsNullOrEmpty(c)) ?? trade.UnderlyingCode; List bondPayments = new List(); if (!string.IsNullOrEmpty(bondCode)) { bondPayments = db.bondPayment .Where(b => b.underlyingCode == bondCode) .OrderBy(b => b.payment_date_pl) .ToList(); } // 4. 诊断:坐实分红重复计算(这是录制测试的核心价值) var diagnosis = DiagnoseDividendDoubleCount(tradeId, flowEvents, eodPositions); Console.WriteLine(diagnosis.Summary); // 5. 序列化为 golden 文件 var golden = new SwapDividendGoldenModel { SwapTradeId = tradeId, SwapTradeNo = trade.TradeNumber, UnderlyingCode = bondCode, RecordedAt = DateTime.Now, SourceDb = "test", Purpose = "分红重复计算验证 + EOD重构回归基线", InputTrade = JObject.FromObject(trade, JsonSerializer.Create(JsonSettings)), InputPositions = JArray.FromObject(positions, JsonSerializer.Create(JsonSettings)), InputFlowEvents = JArray.FromObject(flowEvents, JsonSerializer.Create(JsonSettings)), InputEodPositions = JArray.FromObject(eodPositions, JsonSerializer.Create(JsonSettings)), InputEodSwaps = JArray.FromObject(eodSwaps, JsonSerializer.Create(JsonSettings)), InputBondPayments = JArray.FromObject(bondPayments, JsonSerializer.Create(JsonSettings)), Diagnosis = JObject.FromObject(diagnosis, JsonSerializer.Create(JsonSettings)) }; string fileName = $"dividend_trade_{tradeId}.json"; string filePath = Path.Combine(GoldenDir, fileName); File.WriteAllText(filePath, JsonConvert.SerializeObject(golden, JsonSettings)); Console.WriteLine($"✅ 已保存: {filePath}"); recorded++; } Assert.IsTrue(recorded > 0, "至少应录制 1 笔样本"); Console.WriteLine($"\n录制完成,共 {recorded} 笔,输出目录: {GoldenDir}"); } /// /// 分红重复计算诊断:对照代码行号,把链路数据逐一算出来。 /// 重复计算根因链路(SwapEodPositionService.cs): /// SetPriceInfoByFlowEvent:1610 TdCloseMtmPnl = Σ MarkClosePnl(互换事件里已含分红) /// UpdateEodPosition:1486 RealizedMtmPnL += TdCloseMtmPnl ← 分红第1次(盯市列) /// UpdateEodPosition:1488 TdCloseDividend = Σ DividendIn /// UpdateEodPosition:1494 RealizedDividend += TdCloseDividend ← 分红第2次(分红列) /// SaveEodSwap:1869 eod_swap.RealizedPnL = Σ(RealizedMtmPnL + RealizedDividend + ...) /// → 分红在盯市列和分红列各计一次 = 2 次 /// private DiagnoseResult DiagnoseDividendDoubleCount( int tradeId, List flowEvents, List eodPositions) { var r = new DiagnoseResult { SwapTradeId = tradeId }; var lines = new List { $"--- 分红重复计算诊断 SwapTradeId={tradeId} ---", "", "[流水层] 每条平仓/互换事件拆解 (MarkClosePnl = 价差 + 费CloseFee + 分红DividendIn):", string.Format(" {0,-8}{1,-12}{2,-8}{3,16}{4,12}{5,10}{6,16}", "id", "EventDate", "EvType", "MarkClosePnl", "DividendIn", "CloseFee", "价差(残差)") }; // 仅取完成状态的平仓/互换事件(开仓事件 MarkClosePnl=0 不参与) var closeSwapEvents = flowEvents .Where(e => (e.EventType == (int)SwapFlowEventTypeEnum.平仓 || e.EventType == (int)SwapFlowEventTypeEnum.互换 || e.EventType == (int)SwapFlowEventTypeEnum.自动互换) && e.DataState == (int)SwapFlowDateStateEnum.完成) .ToList(); decimal totalPriceComponent = 0m; // 全交易价差成分合计(用于类型判定) foreach (var e in closeSwapEvents) { string et = e.EventType switch { (int)SwapFlowEventTypeEnum.平仓 => "平仓", (int)SwapFlowEventTypeEnum.互换 => "互换", (int)SwapFlowEventTypeEnum.自动互换 => "自动互换", _ => e.EventType.ToString() }; // 残差 = MarkClosePnl - 分红 - 费 = 纯价差成分 decimal priceComp = e.MarkClosePnl - e.DividendIn - e.CloseFee; totalPriceComponent += priceComp; lines.Add(string.Format(" {0,-8}{1,-12}{2,-8}{3,16:F4}{4,12:F4}{5,10:F4}{6,16:F4}", e.id, e.EventDate.ToString("yyyy-MM-dd"), et, e.MarkClosePnl, e.DividendIn, e.CloseFee, priceComp)); } // ===== 按 PositionId 拆解(避免双向腿抵消)===== lines.Add(""); lines.Add("[EOD层] 按 PositionId 拆解盯市列成分:"); lines.Add(string.Format(" {0,-12}{1,16}{2,16}{3,12}{4,16}{5,16}{6,16}", "PositionId", "盯市列合计", "价差成分", "费成分", "分红成分(重复)", "分红列累计", "重复计入")); decimal totalRepeat = 0m; var evByPos = closeSwapEvents.GroupBy(e => e.PositionId).ToDictionary(g => g.Key, g => g.ToList()); var eodByPos = eodPositions.GroupBy(e => e.PositionId) .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.ValueDate).First()); foreach (var pid in eodByPos.Keys.OrderBy(k => k)) { var evs = evByPos.ContainsKey(pid) ? evByPos[pid] : new List(); decimal mtmTotal = evs.Sum(e => e.MarkClosePnl); decimal feeComp = evs.Sum(e => e.CloseFee); decimal divComp = evs.Sum(e => e.DividendIn); // 盯市列里的分红成分(被重复计入) decimal priceComp = mtmTotal - divComp - feeComp; decimal realizedDiv = eodByPos[pid].RealizedDividend; totalRepeat += divComp; r.逐持仓拆解.Add(new PositionDiagnose { PositionId = pid, 盯市列合计 = mtmTotal, 盯市价差成分 = priceComp, 盯市费成分 = feeComp, 盯市分红成分 = divComp, 分红列累计 = realizedDiv, 重复计入分红 = divComp }); lines.Add(string.Format(" {0,-12}{1,16:F4}{2,16:F4}{3,12:F4}{4,16:F4}{5,16:F4}{6,16:F4}", pid, mtmTotal, priceComp, feeComp, divComp, realizedDiv, divComp)); } // ===== 全交易累计与类型判定 ===== r.最终累计盯市已实现 = eodByPos.Values.Sum(e => e.RealizedMtmPnL); r.最终累计分红已实现 = eodByPos.Values.Sum(e => e.RealizedDividend); r.最终持仓层累计已实现 = eodByPos.Values.Sum(e => e.RealizedPnl); r.重复计入分红金额 = totalRepeat; r.重复计算成立 = Math.Abs(totalRepeat) > 0.01m; r.交易类型 = Math.Abs(totalPriceComponent) < 0.01m ? "纯分红型" : "混合型"; // 结论 if (r.重复计算成立) { r.结论 = string.Format( "⚠ 坐实重复计算:盯市列含分红成分 {0:F4}(既在 RealizedMtmPnL 又在 RealizedDividend)," + "修复后 RealizedPnl 应减少 {0:F4}。类型={1}。", totalRepeat, r.交易类型); } else { r.结论 = "未检测到重复计算(盯市列分红成分≈0,可能已修复或无分红平仓/互换事件)。"; } lines.Add(""); lines.Add("[汇总]"); lines.Add($" 交易类型: {r.交易类型}(价差成分合计={totalPriceComponent:F4})"); lines.Add($" 盯市列里被重复计入的分红成分: {r.重复计入分红金额:F4}"); lines.Add($" 最终 RealizedMtmPnL(盯市列): {r.最终累计盯市已实现:F4}"); lines.Add($" 最终 RealizedDividend(分红列): {r.最终累计分红已实现:F4}"); lines.Add($" 最终 RealizedPnl(持仓层): {r.最终持仓层累计已实现:F4}"); lines.Add($" 重复计算成立: {r.重复计算成立}"); lines.Add($" [结论] {r.结论}"); r.Summary = string.Join("\n", lines); return r; } /// /// Step2:校验已录制 golden 文件的完整性(离线,不连库)。 /// 确认每个 json 含 5 表数据、能正确反序列化。 /// [TestMethod] [TestCategory("DBRecording")] public void Step2_VerifyRecordedGoldenFiles() { if (!Directory.Exists(GoldenDir)) { Assert.Inconclusive($"golden 目录不存在: {GoldenDir}(请先跑 Step1_RecordSampleTrades)"); return; } var files = Directory.GetFiles(GoldenDir, "dividend_trade_*.json"); Assert.IsTrue(files.Length > 0, $"应至少有 1 个 golden 文件 in {GoldenDir}"); foreach (var file in files) { var json = File.ReadAllText(file); var golden = JsonConvert.DeserializeObject(json); Assert.IsTrue(golden.SwapTradeId > 0, $"{file}: SwapTradeId 无效"); Assert.IsNotNull(golden.InputFlowEvents, $"{file}: InputFlowEvents 缺失"); Assert.IsTrue(golden.InputFlowEvents.Count > 0, $"{file}: InputFlowEvents 为空"); Assert.IsNotNull(golden.InputEodPositions, $"{file}: InputEodPositions 缺失"); Assert.IsNotNull(golden.Diagnosis, $"{file}: Diagnosis 缺失"); Console.WriteLine($"✅ {Path.GetFileName(file)}: trade={golden.SwapTradeId}, " + $"flow={golden.InputFlowEvents.Count}条, eod={golden.InputEodPositions.Count}条, " + $"结论={golden.Diagnosis?["结论"]?.Value()}"); } } } /// /// 分红黄金文件数据模型。5 表快照 + 诊断结论。 /// 字段名沿用数据库实体类名,反序列化时类型一致。 /// public class SwapDividendGoldenModel { public int SwapTradeId { get; set; } public string SwapTradeNo { get; set; } public string UnderlyingCode { get; set; } public DateTime RecordedAt { get; set; } public string SourceDb { get; set; } public string Purpose { get; set; } public JObject InputTrade { get; set; } public JArray InputPositions { get; set; } // swap_position public JArray InputFlowEvents { get; set; } // swap_flow_event(分红核心) public JArray InputEodPositions { get; set; } // eod_swap_position(重复计算发生处) public JArray InputEodSwaps { get; set; } // eod_swap(汇总层) public JArray InputBondPayments { get; set; } // bond_payment_info(理论应付) public JObject Diagnosis { get; set; } } /// /// 重复计算诊断结果,随 golden 一起持久化,便于修复后对比。 /// 拆解原理:每条平仓/互换事件的 MarkClosePnl = 价差成分 + 费成分(CloseFee) + 分红成分(DividendIn)。 /// 盯市列(TdCloseMtmPnl=ΣMarkClosePnl) 含了分红成分一份,分红列(TdCloseDividend=ΣDividendIn) 又含一份, /// 故"重复金额" = 进入盯市列的分红成分 = Σ(事件 DividendIn)。按 PositionId 分别拆解避免双向腿抵消。 /// public class DiagnoseResult { public int SwapTradeId { get; set; } /// "纯分红型"(价差成分≈0) 或 "混合型"(价差成分≠0)。基于 MarkClosePnl 是否含价差判定。 public string 交易类型 { get; set; } // ===== 逐 PositionId 拆解 ===== public List 逐持仓拆解 { get; set; } = new List(); /// 盯市列里被重复计入的分红成分合计(=修复后 RealizedPnl 应减少的金额)。 public decimal 重复计入分红金额 { get; set; } public decimal 最终累计盯市已实现 { get; set; } public decimal 最终累计分红已实现 { get; set; } public decimal 最终持仓层累计已实现 { get; set; } /// 若重复计入分红金额≠0,则重复计算成立。 public bool 重复计算成立 { get; set; } public string 结论 { get; set; } public string Summary { get; set; } } /// /// 单个持仓腿(PositionId)的拆解结果。 /// public class PositionDiagnose { public long PositionId { get; set; } /// 该腿盯市列合计 = Σ 事件 MarkClosePnl。 public decimal 盯市列合计 { get; set; } /// 盯市列里的价差成分 = Σ(MarkClosePnl - DividendIn - CloseFee)。 public decimal 盯市价差成分 { get; set; } /// 盯市列里的费成分 = Σ CloseFee。 public decimal 盯市费成分 { get; set; } /// 盯市列里的分红成分 = Σ DividendIn(这是被重复计入的部分)。 public decimal 盯市分红成分 { get; set; } /// 该腿分红列最终累计 RealizedDividend。 public decimal 分红列累计 { get; set; } /// 该腿被重复计入的分红 = 盯市分红成分。 public decimal 重复计入分红 { get; set; } } }