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)。
This commit is contained in:
hjhan
2026-06-26 09:13:37 +08:00
parent 9e6af84e47
commit 029475ce8d
7 changed files with 8100 additions and 3 deletions
@@ -0,0 +1,426 @@
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; }
}
}