Files
zszq-trs/UnitTestProject/Modules/SwapModule/SwapDividendGoldenRecordTest.cs
T
hjhan 029475ce8d test(swap): 新增互换分红重复计算录制测试+排查SQL(TDD红灯)
分红被重复计入 RealizedPnl 两次的根因:互换/平仓事件的 MarkClosePnl
已含分红成分(DividendIn),导致分红既进 RealizedMtmPnL(盯市列) 又进
RealizedDividend(分红列)。本次提供数据证据与回归基线:

- SwapDividendGoldenRecordTest: 连库录制1875(纯分红型)/1891(混合型)
  5表快照→JSON golden,录制时逐事件拆解 MarkClosePnl=价差+费+分红,
  按 PositionId 汇总出重复计入的分红金额。Step2 离线校验已通过。
  诊断结论:1875 重复302400,1891 重复-18.66,均与 eod 分红列吻合。
- golden JSON 纳入源码树(全量复制规则),修复后可作回归基线。
- 排查SQL工具箱: 基于真实DDL核对全部列名(swap_flow_event.EventDate/
  bond_payment_info.paying_interest 等),含定位样本/导出/验证/守恒4步。
- appsettings.json 测试库 zszq→glms 前缀修正。

详见 项目文档/互换分红损益字段语义与重复计算分析.md(9e6af84e)。
2026-06-26 09:13:37 +08:00

427 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace YLErp.Modules.SwapModule
{
/// <summary>
/// 互换分红损益 - 黄金文件录制测试(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 };
/// <summary>
/// Step0:列出库中所有"带分红的互换交易",确认样本有效性。
/// 打印 SwapTradeId / 分红合计 / 盯市合计 / eod快照数,供挑选样本。
///
/// 可直接运行:连不上测试库时返回 Inconclusive(不计入失败),不挡 CI;
/// 连得上时输出诊断表。这是日常排查"库里有啥分红交易"的入口。
/// </summary>
[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();
}
}
/// <summary>
/// Step1:逐笔录制样本交易的完整快照(5表),并输出分红重复计算诊断。
///
/// 为何保留 [Ignore]:本方法有写文件副作用(落 golden JSON),
/// 不应随每次构建/CI 自动执行;只在需要"刷新 golden 种子"时手动触发。
/// 运行方式(三选一):
/// - VS 测试资源管理器:选中本方法 → 右键 → 运行(VS 默认会跑被 Ignore 的,除非全局过滤)
/// - 命令行:dotnet test --filter "FullyQualifiedName~Step1_RecordSampleTrades"
/// - 临时:删掉本方法上的 [Ignore] 再跑,跑完恢复
/// </summary>
[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<BondPayment> bondPayments = new List<BondPayment>();
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}");
}
/// <summary>
/// 分红重复计算诊断:对照代码行号,把链路数据逐一算出来。
/// 重复计算根因链路(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 次
/// </summary>
private DiagnoseResult DiagnoseDividendDoubleCount(
int tradeId,
List<swap_flow_event> flowEvents,
List<eod_swap_position> eodPositions)
{
var r = new DiagnoseResult { SwapTradeId = tradeId };
var lines = new List<string>
{
$"--- 分红重复计算诊断 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<swap_flow_event>();
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;
}
/// <summary>
/// Step2:校验已录制 golden 文件的完整性(离线,不连库)。
/// 确认每个 json 含 5 表数据、能正确反序列化。
/// </summary>
[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<SwapDividendGoldenModel>(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<string>()}");
}
}
}
/// <summary>
/// 分红黄金文件数据模型。5 表快照 + 诊断结论。
/// 字段名沿用数据库实体类名,反序列化时类型一致。
/// </summary>
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; }
}
/// <summary>
/// 重复计算诊断结果,随 golden 一起持久化,便于修复后对比。
/// 拆解原理:每条平仓/互换事件的 MarkClosePnl = 价差成分 + 费成分(CloseFee) + 分红成分(DividendIn)。
/// 盯市列(TdCloseMtmPnl=ΣMarkClosePnl) 含了分红成分一份,分红列(TdCloseDividend=ΣDividendIn) 又含一份,
/// 故"重复金额" = 进入盯市列的分红成分 = Σ(事件 DividendIn)。按 PositionId 分别拆解避免双向腿抵消。
/// </summary>
public class DiagnoseResult
{
public int SwapTradeId { get; set; }
/// <summary>"纯分红型"(价差成分≈0) 或 "混合型"(价差成分≠0)。基于 MarkClosePnl 是否含价差判定。</summary>
public string 交易类型 { get; set; }
// ===== 逐 PositionId 拆解 =====
public List<PositionDiagnose> 逐持仓拆解 { get; set; } = new List<PositionDiagnose>();
/// <summary>盯市列里被重复计入的分红成分合计(=修复后 RealizedPnl 应减少的金额)。</summary>
public decimal 重复计入分红金额 { get; set; }
public decimal 最终累计盯市已实现 { get; set; }
public decimal 最终累计分红已实现 { get; set; }
public decimal 最终持仓层累计已实现 { get; set; }
/// <summary>若重复计入分红金额≠0,则重复计算成立。</summary>
public bool 重复计算成立 { get; set; }
public string 结论 { get; set; }
public string Summary { get; set; }
}
/// <summary>
/// 单个持仓腿(PositionId)的拆解结果。
/// </summary>
public class PositionDiagnose
{
public long PositionId { get; set; }
/// <summary>该腿盯市列合计 = Σ 事件 MarkClosePnl。</summary>
public decimal 盯市列合计 { get; set; }
/// <summary>盯市列里的价差成分 = Σ(MarkClosePnl - DividendIn - CloseFee)。</summary>
public decimal 盯市价差成分 { get; set; }
/// <summary>盯市列里的费成分 = Σ CloseFee。</summary>
public decimal 盯市费成分 { get; set; }
/// <summary>盯市列里的分红成分 = Σ DividendIn(这是被重复计入的部分)。</summary>
public decimal 盯市分红成分 { get; set; }
/// <summary>该腿分红列最终累计 RealizedDividend。</summary>
public decimal 分红列累计 { get; set; }
/// <summary>该腿被重复计入的分红 = 盯市分红成分。</summary>
public decimal 重复计入分红 { get; set; }
}
}